From df6dc3dd90302431cf0163e4e56a38af37f5e72c Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:54:37 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(terminal):=20print=20what=20a=20real=20?= =?UTF-8?q?shell=20prints=20=E2=80=94=20pwd,=20cd=20-,=20export,=20ls,=20w?= =?UTF-8?q?c,=20apt=20(THI-353)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Theory replay of the lessons (757 commands) against the engine: 42 more examples now match, expected values taken from real bash / PowerShell. - pwd prints the absolute path (the prompt shortens to ~, pwd never does) - cd - returns to $OLDPWD (previousCwd), bash prints it, PowerShell silent - export expands its value (`export PATH=$PATH:/opt/bin`) - PowerShell shows a Windows PATH ($env:PATH, echo $env:PATH), never written back to the shared state - git init creates the directory and the repository in it - wc counts UTF-8 bytes and the final newline (notes.txt: 6 22 143, as the real wc); an empty file is 0 0 0 - ls: one alphabetical list (C locale, no directories-first), no trailing /; -F marks / and * - !! history expansion (`sudo !!`), echoed like bash - apt / apt-get (Linux): changing the system needs sudo, like the real lock error; sudo no longer records the inner command twice in the history - killall, Start-Process (UAC note for -Verb RunAs), Windows-format ping - Linux command names are case-sensitive (LS is not ls); the not-found message repeats the name as typed - rm -rf / hits the GNU failsafe Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/env.ts | 6 +- src/app/data/commands/network.ts | 23 +++- src/app/data/commands/shellVars.ts | 17 +++ src/app/data/commands/types.ts | 2 + src/app/data/commands/windows.ts | 35 ++++- src/app/data/terminalEngine.ts | 213 ++++++++++++++++++++++++----- src/test/shellLayer.test.ts | 5 +- src/test/terminalEngine.test.ts | 139 ++++++++++++++++++- 8 files changed, 391 insertions(+), 49 deletions(-) create mode 100644 src/app/data/commands/shellVars.ts diff --git a/src/app/data/commands/env.ts b/src/app/data/commands/env.ts index aa53e1bc..1d6d60eb 100644 --- a/src/app/data/commands/env.ts +++ b/src/app/data/commands/env.ts @@ -16,7 +16,11 @@ export function cmdExport(args: string[], state: TerminalState): { lines: Output lines.push({ text: '', type: 'output' }); } else { const name = arg.slice(0, eqIdx); - const value = arg.slice(eqIdx + 1).replace(/^["']|["']$/g, ''); + // Bash expands the value when it is assigned: `export PATH=$PATH:/opt/bin`. + const value = arg + .slice(eqIdx + 1) + .replace(/^["']|["']$/g, '') + .replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (_, ref: string) => newEnv[ref] ?? ''); if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { lines.push({ text: `export: '${name}': not a valid identifier`, type: 'error' }); } else { diff --git a/src/app/data/commands/network.ts b/src/app/data/commands/network.ts index f58d761a..0f7aac6a 100644 --- a/src/app/data/commands/network.ts +++ b/src/app/data/commands/network.ts @@ -1,10 +1,29 @@ -import type { TerminalState, CommandOutput } from './types'; +import type { TerminalState, TerminalEnv, CommandOutput } from './types'; -export function handleNetwork(cmd: string, args: string[], newState: TerminalState): CommandOutput { +/** A stable address per host, so the output reads like the lessons. */ +function fakeIp(host: string): string { + return host === 'google.com' ? '142.250.74.46' : '93.184.216.34'; +} + +export function handleNetwork(cmd: string, args: string[], newState: TerminalState, env: TerminalEnv = 'linux'): CommandOutput { switch (cmd) { case 'ping': { const host = args.find((a) => !a.startsWith('-') && isNaN(Number(a))) ?? ''; if (!host) return { lines: [{ text: 'Usage: ping ', type: 'error' }], newState }; + if (env === 'windows') { + // Windows ping: 4 echo requests, its own wording. + const ip = fakeIp(host); + return { + lines: [ + { text: `Pinging ${host} [${ip}] with 32 bytes of data:`, type: 'output' }, + ...[12, 11, 12, 13].map((ms) => ({ text: `Reply from ${ip}: bytes=32 time=${ms}ms TTL=117`, type: 'output' as const })), + { text: '', type: 'output' }, + { text: `Ping statistics for ${ip}:`, type: 'output' }, + { text: ' Packets: Sent = 4, Received = 4, Lost = 0 (0% loss),', type: 'success' }, + ], + newState, + }; + } return { lines: [ { text: `PING ${host}: 56 data bytes`, type: 'output' }, diff --git a/src/app/data/commands/shellVars.ts b/src/app/data/commands/shellVars.ts new file mode 100644 index 00000000..60c8be2f --- /dev/null +++ b/src/app/data/commands/shellVars.ts @@ -0,0 +1,17 @@ +import type { TerminalEnv } from './types'; + +/** PATH of the simulated Linux / macOS session. */ +export const UNIX_DEFAULT_PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; + +/** What `$env:PATH` shows on a fresh Windows session (the lessons print this value). */ +export const WINDOWS_DEFAULT_PATH = 'C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin'; + +/** + * The variables as the given shell shows them. The session keeps one set of + * variables; an untouched PATH reads as a Windows PATH under PowerShell. + * Never written back to the state: switching environment keeps each view. + */ +export function varsForEnv(vars: Record, env: TerminalEnv): Record { + if (env === 'windows' && vars.PATH === UNIX_DEFAULT_PATH) return { ...vars, PATH: WINDOWS_DEFAULT_PATH }; + return vars; +} diff --git a/src/app/data/commands/types.ts b/src/app/data/commands/types.ts index 27fafef1..e4d0251d 100644 --- a/src/app/data/commands/types.ts +++ b/src/app/data/commands/types.ts @@ -46,6 +46,8 @@ export interface GitState { export interface TerminalState { root: DirectoryNode; cwd: string[]; + /** Directory before the last `cd` ($OLDPWD), for `cd -`. */ + previousCwd?: string[]; commandHistory: string[]; user: string; hostname: string; diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index 2c05803d..12a382e7 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -1,8 +1,9 @@ import type { TerminalState, TerminalEnv, CommandOutput, OutputLine, DirectoryNode } from './types'; +import { varsForEnv } from './shellVars'; export interface WindowsCmdDeps { cmdPwd: (state: TerminalState, env: TerminalEnv) => OutputLine[]; - cmdCd: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newCwd?: string[] }; + cmdCd: (state: TerminalState, args: string[], env?: TerminalEnv) => { lines: OutputLine[]; newCwd?: string[] }; cmdLs: (state: TerminalState, args: string[]) => OutputLine[]; cmdCat: (state: TerminalState, args: string[]) => OutputLine[]; cmdMkdir: (state: TerminalState, args: string[]) => { lines: OutputLine[]; newRoot?: DirectoryNode }; @@ -17,6 +18,12 @@ export interface WindowsCmdDeps { const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default']; +/** Value that follows a PowerShell parameter (`-Verb RunAs`), case-insensitive. */ +function psParam(args: string[], name: string): string | undefined { + const i = args.findIndex((a) => a.toLowerCase() === name); + return i >= 0 ? args[i + 1] : undefined; +} + /** `-Path x`, `-LiteralPath x`, or the first positional argument. */ function psPath(args: string[]): string | undefined { const i = args.findIndex((a) => ['-path', '-literalpath'].includes(a.toLowerCase())); @@ -43,8 +50,8 @@ export function handleWindows( // ── cd equivalents ──────────────────────────────────────────────────────── case 'set-location': case 'sl': { - const { lines, newCwd } = deps.cmdCd(newState, args); - if (newCwd) newState = { ...newState, cwd: newCwd }; + const { lines, newCwd } = deps.cmdCd(newState, args, env); + if (newCwd) newState = { ...newState, cwd: newCwd, previousCwd: newState.cwd }; return { lines, newState }; } @@ -71,6 +78,25 @@ export function handleWindows( return { lines: [{ text: target, type: 'output' }], newState }; } + // ── Start-Process: opens a program in a new window (nothing to show here) ── + case 'start-process': + case 'saps': + case 'start': { + if (env !== 'windows') return null; + const program = psParam(args, '-filepath') ?? args.find((a) => !a.startsWith('-')); + if (!program) return { lines: [{ text: 'Start-Process: indiquez un programme, par exemple Start-Process notepad', type: 'error' }], newState }; + const asAdmin = args.some((a) => a.toLowerCase() === '-verb') && /runas/i.test(psParam(args, '-verb') ?? ''); + return { + lines: [{ + text: asAdmin + ? `(Windows demanderait une confirmation (UAC), puis ouvrirait « ${program} » en administrateur dans une nouvelle fenêtre.)` + : `(« ${program} » s'ouvrirait dans une nouvelle fenêtre.)`, + type: 'info', + }], + newState, + }; + } + // ── cat equivalents ─────────────────────────────────────────────────────── case 'get-content': case 'gc': @@ -132,7 +158,7 @@ export function handleWindows( // ── echo equivalents ────────────────────────────────────────────────────── case 'write-host': case 'write-output': - return { lines: deps.cmdEcho(args.filter((a) => !a.startsWith('-')), newState.envVars), newState }; + return { lines: deps.cmdEcho(args.filter((a) => !a.startsWith('-')), varsForEnv(newState.envVars, env)), newState }; // ── ps equivalents ──────────────────────────────────────────────────────── case 'get-process': @@ -319,6 +345,7 @@ export const WINDOWS_COMMANDS = new Set([ 'get-childitem', 'gci', 'dir', 'get-content', 'gc', 'get-item', 'gi', + 'start-process', 'saps', 'start', 'new-item', 'ni', 'copy-item', 'cpi', 'copy', 'move-item', 'mi', 'move', diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 7f9a5bda..05993d4c 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -10,6 +10,7 @@ import { cmdEnv, handleEnv } from './commands/env'; import { handleWindows } from './commands/windows'; import { parseCommandLine, isPlainCommand, isNullDevice } from './commands/shellSyntax'; import type { Stage, Fd } from './commands/shellSyntax'; +import { UNIX_DEFAULT_PATH, varsForEnv } from './commands/shellVars'; import type { WindowsCmdDeps } from './commands/windows'; // ─── Initial Filesystem ─────────────────────────────────────────────────────── @@ -87,7 +88,7 @@ export function createInitialState(): TerminalState { user: 'user', hostname: 'terminal-lab', envVars: { - PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin', + PATH: UNIX_DEFAULT_PATH, HOME: '/home/user', USER: 'user', SHELL: '/bin/bash', @@ -302,8 +303,13 @@ function formatLongEntry(name: string, node: FSNode): string { // ─── Command Handlers ───────────────────────────────────────────────────────── +/** The absolute path, as `pwd` prints it (the prompt shortens it to `~`, pwd never does). */ +function absolutePath(cwd: string[], env: TerminalEnv): string { + return env === 'windows' ? displayPathForEnv(cwd, env) : '/' + cwd.join('/'); +} + function cmdPwd(state: TerminalState, env: TerminalEnv = 'linux'): OutputLine[] { - return [{ text: displayPathForEnv(state.cwd, env), type: 'output' }]; + return [{ text: absolutePath(state.cwd, env), type: 'output' }]; } function cmdLs(state: TerminalState, args: string[]): OutputLine[] { @@ -322,15 +328,10 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { : [{ text: paths[0] || '', type: 'output' }]; } + // Like `ls` in the C locale: one alphabetical list, hidden files first, no directories-first. const entries = Object.entries(node.children) .filter(([name]) => showAll || !name.startsWith('.')) - .sort(([a], [b]) => { - const aIsDir = node.children[a].type === 'directory'; - const bIsDir = node.children[b].type === 'directory'; - if (aIsDir && !bIsDir) return -1; - if (!aIsDir && bIsDir) return 1; - return a.localeCompare(b); - }); + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); if (showAll) { entries.unshift(['.', node], ['..', node]); @@ -340,7 +341,13 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { // 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 })); - const names = entries.map(([name, n]) => (n.type === 'directory' ? name + '/' : name)); + // `-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 })); return [{ text: names.join(' '), type: 'output' }]; } @@ -352,8 +359,16 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { return lines; } -function cmdCd(state: TerminalState, args: string[]): { lines: OutputLine[]; newCwd?: string[] } { +function cmdCd(state: TerminalState, args: string[], env: TerminalEnv = 'linux'): { lines: OutputLine[]; newCwd?: string[] } { const target = args[0]; + if (target === '-') { + // `cd -` returns to $OLDPWD; bash prints where it lands, PowerShell stays silent. + if (!state.previousCwd) { + return { lines: env === 'windows' ? [] : [{ text: 'bash: cd: OLDPWD not set', type: 'error' }] }; + } + const lines: OutputLine[] = env === 'windows' ? [] : [{ text: absolutePath(state.previousCwd, env), type: 'output' }]; + return { lines, newCwd: state.previousCwd }; + } if (!target || target === '~') { return { lines: [], newCwd: ['home', 'user'] }; } @@ -468,6 +483,13 @@ function cmdRm(state: TerminalState, args: string[]): { lines: OutputLine[]; new const recursive = flags.some((f) => f.includes('r') || f.includes('R')); if (!paths.length) return { lines: [{ 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: [ + { text: "rm: it is dangerous to operate recursively on '/'", type: 'error' }, + { text: 'rm: use --no-preserve-root to override this failsafe', type: 'error' }, + ] }; + } const newRoot = deepCloneRoot(state.root); @@ -619,6 +641,21 @@ function cmdTail(state: TerminalState, args: string[]): OutputLine[] { return cmdHeadTail(state, args, 'tail'); } +const utf8 = new TextEncoder(); + +/** + * What `wc` counts. The simulator stores text without its final newline; a real + * file (and a real pipe) ends with one, and `wc -c` counts bytes, not characters. + */ +function textCounts(text: string): { lines: number; words: number; bytes: number } { + if (text === '') return { lines: 0, words: 0, bytes: 0 }; + return { + lines: text.split('\n').length, + words: text.split(/\s+/).filter(Boolean).length, + bytes: utf8.encode(text).length + 1, + }; +} + function cmdWc(state: TerminalState, args: string[]): OutputLine[] { const flags = args.filter((a) => a.startsWith('-')); const paths = args.filter((a) => !a.startsWith('-')); @@ -629,9 +666,7 @@ function cmdWc(state: TerminalState, args: string[]): OutputLine[] { const node = getNode(state.root, resolvePath(state, p)); if (!node) { lines.push({ text: `wc: ${p}: No such file or directory`, type: 'error' }); continue; } if (node.type === 'directory') { lines.push({ text: `wc: ${p}: Is a directory`, type: 'error' }); continue; } - const lc = node.content.split('\n').length; - const wc = node.content.split(/\s+/).filter(Boolean).length; - const cc = node.content.length; + const { lines: lc, words: wc, bytes: cc } = textCounts(node.content); if (flags.some((f) => f.includes('l'))) lines.push({ text: ` ${lc} ${p}`, type: 'output' }); else if (flags.some((f) => f.includes('w'))) lines.push({ text: ` ${wc} ${p}`, type: 'output' }); else if (flags.some((f) => f.includes('c'))) lines.push({ text: ` ${cc} ${p}`, type: 'output' }); @@ -718,9 +753,64 @@ function cmdSudo(args: string[], state: TerminalState, env: TerminalEnv): Comman ], newState: state }; } // Run the command as root — just delegate but mark it as sudo - const subCmd = args.join(' '); - const subResult = processCommand(state, subCmd, env); - return subResult; + // Run the command as root. runLine, not processCommand: the history already + // holds `sudo …`, the inner command must not be recorded a second time. + const wasRoot = runningAsRoot; + runningAsRoot = true; + try { + return runLine(state, args.join(' '), env); + } finally { + runningAsRoot = wasRoot; + } +} + +/** True while `sudo` runs a command (apt refuses to change the system otherwise). */ +let runningAsRoot = false; + +const APT_NEEDS_ROOT = new Set(['update', 'upgrade', 'install', 'remove', 'purge', 'autoremove', 'full-upgrade']); + +function cmdApt(cmd: string, args: string[]): OutputLine[] { + const sub = args[0] ?? ''; + const pkgs = args.slice(1).filter((a) => !a.startsWith('-')); + if (!sub) return [{ text: `Usage: ${cmd} update | upgrade | install | remove | search `, type: 'error' }]; + if (APT_NEEDS_ROOT.has(sub) && !runningAsRoot) { + return [ + { text: 'E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)', type: 'error' }, + { text: 'E: Unable to acquire the dpkg frontend lock (/var/lib/dpkg/lock-frontend), are you root?', type: 'error' }, + ]; + } + switch (sub) { + case 'update': + return [ + { text: 'Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease', type: 'output' }, + { text: 'Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB]', type: 'output' }, + { text: 'Reading package lists... Done', type: 'output' }, + { text: 'All packages are up to date.', type: 'success' }, + ]; + case 'upgrade': + case 'full-upgrade': + return [ + { text: 'Reading package lists... Done', type: 'output' }, + { text: '0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.', type: 'success' }, + ]; + case 'install': + if (!pkgs.length) return [{ text: 'E: Aucun paquet indiqué — par exemple : sudo apt install tree', type: 'error' }]; + return [ + { text: 'Reading package lists... Done', type: 'output' }, + { text: `The following NEW packages will be installed: ${pkgs.join(' ')}`, type: 'output' }, + ...pkgs.map((p) => ({ text: `Setting up ${p} ...`, type: 'success' as const })), + ]; + case 'remove': + case 'purge': + case 'autoremove': + return pkgs.map((p) => ({ text: `Removing ${p} ...`, type: 'success' as const })); + case 'search': + case 'show': + case 'list': + return [{ text: `(recherche simulée : ${pkgs.join(' ') || 'tous les paquets'} — sur une vraie machine, apt interroge les dépôts Ubuntu)`, type: 'info' }]; + default: + return [{ text: `E: Invalid operation ${sub}`, type: 'error' }]; + } } function cmdTop(state: TerminalState): OutputLine[] { @@ -950,13 +1040,11 @@ function runFilter(state: TerminalState, text: string, stdin: string, env: Termi switch (cmd) { case 'wc': { - const lines = stdin.split('\n').filter(Boolean); - const words = stdin.split(/\s+/).filter(Boolean); - const bytes = stdin.length; - if (flags.some((f) => f.includes('l'))) return same(outLines([String(lines.length)])); - if (flags.some((f) => f.includes('w'))) return same(outLines([String(words.length)])); + const { lines, words, bytes } = textCounts(stdin); + if (flags.some((f) => f.includes('l'))) return same(outLines([String(lines)])); + if (flags.some((f) => f.includes('w'))) return same(outLines([String(words)])); if (flags.some((f) => f.includes('c'))) return same(outLines([String(bytes)])); - return same(outLines([`${lines.length} ${words.length} ${bytes}`])); + return same(outLines([`${lines} ${words} ${bytes}`])); } case 'grep': { @@ -1704,12 +1792,43 @@ function getCmdHelp(cmdName: string, env: TerminalEnv = 'linux'): OutputLine[] | // ─── Main Command Processor ─────────────────────────────────────────────────── export function processCommand(state: TerminalState, input: string, env: TerminalEnv = 'linux'): CommandOutput { - const trimmed = input.trim(); + let trimmed = input.trim(); + let expandedFrom: OutputLine | null = null; + + // Bash history expansion: `!!` is the previous command (`sudo !!`), outside single quotes. + if (env !== 'windows' && hasHistoryBang(trimmed)) { + const previous = state.commandHistory[state.commandHistory.length - 1]; + if (previous === undefined) return { lines: [{ text: 'bash: !!: event not found', type: 'error' }], newState: state }; + trimmed = expandHistoryBang(trimmed, previous); + // Bash prints the expanded line before running it. + expandedFrom = { text: trimmed, type: 'info' }; + } + const newHistory = trimmed ? [...state.commandHistory, trimmed] : state.commandHistory; const newState: TerminalState = { ...state, commandHistory: newHistory }; if (!trimmed) return { lines: [], newState }; - return runLine(newState, trimmed, env); + const result = runLine(newState, trimmed, env); + return expandedFrom && !result.clear ? { ...result, lines: [expandedFrom, ...result.lines] } : result; +} + +/** `!!` outside single quotes. */ +function hasHistoryBang(line: string): boolean { + return splitSingleQuoted(line).some((part, i) => i % 2 === 0 && part.includes('!!')); +} + +function expandHistoryBang(line: string, previous: string): string { + return splitSingleQuoted(line).map((part, i) => (i % 2 === 0 ? part.split('!!').join(previous) : `'${part}'`)).join(''); +} + +/** Even indexes: outside single quotes; odd indexes: the quoted text, quotes removed. */ +function splitSingleQuoted(line: string): string[] { + return line.split("'"); +} + +/** The command name as typed (not lower-cased): that is what a real shell repeats. */ +function commandNotFound(typed: string, state: TerminalState): CommandOutput { + return { lines: [{ text: `${typed}: commande introuvable. Tapez 'help' pour la liste des commandes.`, type: 'error' }], newState: state }; } /** One command, without list, pipe or redirection — the shell layer handles those. */ @@ -1731,7 +1850,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com const psEnvGet = trimmed.match(/^\$env:([A-Za-z_][A-Za-z0-9_]*)$/); if (psEnvGet) { const [, varName] = psEnvGet; - const value = newState.envVars[varName]; + const value = varsForEnv(newState.envVars, env)[varName]; return { lines: value !== undefined ? [{ text: value, type: 'output' }] @@ -1765,6 +1884,11 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } const args = parts.slice(1); + // Linux looks commands up case-sensitively: `LS` is not `ls`. (macOS disks and + // PowerShell ignore case; names with a dash are PowerShell cmdlets.) + // Only bare names: a path (`./README.md`) keeps its case and is a file, not a command. + if (env === 'linux' && parts[0] !== cmd && !/[-/\\]/.test(parts[0])) return commandNotFound(parts[0], newState); + const script = scriptCall(parts, env); if (script) return runScript(newState, script, env); @@ -1782,8 +1906,8 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines: cmdLs(newState, args), newState }; case 'cd': { - const { lines, newCwd } = cmdCd(newState, args); - if (newCwd) newState = { ...newState, cwd: newCwd }; + const { lines, newCwd } = cmdCd(newState, args, env); + if (newCwd) newState = { ...newState, cwd: newCwd, previousCwd: newState.cwd }; return { lines, newState }; } @@ -1803,7 +1927,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com return { lines: cmdCat(newState, args), newState }; case 'echo': - return { lines: cmdEcho(args, newState.envVars), newState }; + return { lines: cmdEcho(args, varsForEnv(newState.envVars, env)), newState }; // ── Environment & scripts → commands/env.ts ─────────────────────────────── case 'export': @@ -1964,6 +2088,18 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com }; } + case 'apt': + case 'apt-get': + // Debian / Ubuntu package manager: Linux only. + if (env !== 'linux') return commandNotFound(parts[0], newState); + return { lines: cmdApt(cmd, args), newState }; + + case 'killall': + if (env === 'windows') return commandNotFound(parts[0], newState); + // Real killall prints nothing on success. + if (!args.filter((a) => !a.startsWith('-')).length) return { lines: [{ text: 'killall: usage: killall [-s signal] nom', type: 'error' }], newState }; + return { lines: [], newState }; + case 'kill': if (!args.length) return { lines: [{ text: 'kill: usage: kill PID', type: 'error' }], newState }; return { lines: [{ text: `Signal envoyé au processus ${args[args.length - 1]}`, type: 'success' }], newState }; @@ -2042,11 +2178,21 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com case 'ssh': case 'ssh-keygen': case 'scp': - return handleNetwork(cmd, args, newState); + return handleNetwork(cmd, args, newState, env); // ── Git (Modules 9 & 10) → commands/git.ts ─────────────────────────────── - case 'git': + case 'git': { + // `git init ` creates the directory and initialises the repository inside it. + const initDir = args[0]?.toLowerCase() === 'init' ? args.slice(1).find((a) => !a.startsWith('-')) : undefined; + if (initDir) { + const made = cmdMkdir(newState, ['-p', initDir]); + if (!made.newRoot) return { lines: made.lines, newState }; + const inside = { ...newState, root: made.newRoot, cwd: resolvePath(newState, initDir) }; + const r = handleGit(inside, ['init'], env); + return { ...r, newState: { ...r.newState, cwd: newState.cwd } }; + } return handleGit(newState, args, env); + } // ── IA (Module 11) → commands/ai.ts ────────────────────────────────────── case 'ai-help': @@ -2056,10 +2202,7 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com default: { const winResult = handleWindows(cmd, args, newState, env, winDeps); if (winResult !== null) return winResult; - return { - lines: [{ text: `${cmd}: commande introuvable. Tapez 'help' pour la liste des commandes.`, type: 'error' }], - newState, - }; + return commandNotFound(parts[0], newState); } } } diff --git a/src/test/shellLayer.test.ts b/src/test/shellLayer.test.ts index 00ab657b..aa8fd415 100644 --- a/src/test/shellLayer.test.ts +++ b/src/test/shellLayer.test.ts @@ -163,7 +163,8 @@ describe('pipelines', () => { it('ls writes columns on screen, one name per line into a pipe or a file, and with -1', () => { expect(run('linux', 'ls').last).toHaveLength(1); - expect(text(run('linux', 'ls -1').last)).toBe(['documents/', 'downloads/', 'projets/'].join('\n')); + // Plain ls marks nothing; -F adds the / (THI-353). + expect(text(run('linux', 'ls -1').last)).toBe(['documents', 'downloads', 'projets'].join('\n')); expect(text(run('linux', 'ls | cat').last)).toBe('documents\ndownloads\nprojets'); }); @@ -210,7 +211,7 @@ describe('pipelines', () => { it('a command that does not read stdin still runs after a pipe', () => { const { state, last } = run('linux', 'echo hi | mkdir test'); expect(last).toEqual([]); - expect(text(processCommand(state, 'ls -1', 'linux').lines)).toContain('test/'); + expect(processCommand(state, 'ls -1', 'linux').lines.map((l) => l.text)).toContain('test'); expect(text(run('linux', 'echo hi | whoami').last)).toBe('user'); }); diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 43f78f72..9e0a4a9e 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -611,16 +611,17 @@ describe('displayPathForEnv — path formatting per env', () => { }); describe('pwd — env-aware path output', () => { - it('linux → ~ for home directory', () => { + // A real pwd prints the absolute path; only the prompt shortens it to ~ (THI-353). + it('linux → /home/user for home directory', () => { const state = createInitialState(); const result = processCommand(state, 'pwd', 'linux'); - expect(result.lines[0].text).toBe('~'); + expect(result.lines[0].text).toBe('/home/user'); }); - it('macos → ~ for home directory', () => { + it('macos → /home/user for home directory (the simulator keeps one home for bash and zsh)', () => { const state = createInitialState(); const result = processCommand(state, 'pwd', 'macos'); - expect(result.lines[0].text).toBe('~'); + expect(result.lines[0].text).toBe('/home/user'); }); it('windows → C:\\Users\\user for home directory', () => { @@ -1272,7 +1273,9 @@ describe('ls', () => { it('lists files and directories', () => { const result = processCommand(makeStateWithFS(), 'ls'); const text = result.lines[0].text; - expect(text).toContain('docs/'); + // Plain ls marks nothing (no trailing /); ls -F does (THI-353). + expect(text).toContain('docs'); + expect(text).not.toContain('docs/'); expect(text).toContain('test.txt'); }); @@ -2489,3 +2492,129 @@ describe('Get-Item and pipeline cmdlets', () => { expect(out(s, 'git log | grep -c commit', 'linux')).toBe('1'); }); }); + +// ─── THI-353: what the lessons show must be what the terminal prints ───────── +// Expected values come from real bash / PowerShell, never from this engine. + +describe('theory ↔ terminal: engine fidelity', () => { + type Env = 'linux' | 'macos' | 'windows'; + function build(env: Env, ...cmds: string[]): TerminalState { + let s = createInitialState(); + for (const c of cmds) s = processCommand(s, c, env).newState; + return s; + } + const out = (s: TerminalState, cmd: string, env: Env = 'linux') => + processCommand(s, cmd, env).lines.map((l) => l.text).join('\n'); + const types = (s: TerminalState, cmd: string, env: Env = 'linux') => processCommand(s, cmd, env).lines.map((l) => l.type); + + it('pwd prints the absolute path, never ~', () => { + expect(out(createInitialState(), 'pwd')).toBe('/home/user'); + expect(out(build('linux', 'cd documents'), 'pwd')).toBe('/home/user/documents'); + expect(out(build('macos', 'cd documents'), 'pwd', 'macos')).toBe('/home/user/documents'); + expect(out(createInitialState(), 'Get-Location', 'windows')).toBe('C:\\Users\\user'); + }); + + it('cd - goes back to the previous directory and prints it', () => { + const s = build('linux', 'cd documents', 'cd ..'); + const r = processCommand(s, 'cd -', 'linux'); + expect(r.lines.map((l) => l.text)).toEqual(['/home/user/documents']); + expect(r.newState.cwd).toEqual(['home', 'user', 'documents']); + expect(processCommand(r.newState, 'cd -', 'linux').newState.cwd).toEqual(['home', 'user']); + expect(out(createInitialState(), 'cd -')).toBe('bash: cd: OLDPWD not set'); + }); + + it('export expands the variables of its value', () => { + const s = build('linux', 'export PATH=$PATH:/opt/myapp/bin', 'export SALUT="Bonjour $USER"'); + expect(out(s, 'echo $PATH')).toBe('/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/opt/myapp/bin'); + expect(out(s, 'echo $SALUT')).toBe('Bonjour user'); + }); + + it('PowerShell shows a Windows PATH', () => { + const s = createInitialState(); + expect(out(s, 'echo $env:PATH', 'windows')).toBe('C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin'); + expect(out(s, '$env:PATH', 'windows')).toBe('C:\\Windows\\System32;C:\\Windows;C:\\Program Files\\Git\\bin'); + const t = build('windows', '$env:PATH = "C:\\outils"'); + expect(out(t, '$env:PATH', 'windows')).toBe('C:\\outils'); + }); + + it('git init creates the directory and the repository there', () => { + const r = processCommand(createInitialState(), 'git init mon-projet', 'linux'); + expect(r.lines[0].text).toBe('Initialized empty Git repository in /home/user/mon-projet/.git/'); + expect(r.newState.cwd).toEqual(['home', 'user']); + expect(out(r.newState, 'ls')).toContain('mon-projet'); + }); + + it('wc counts bytes (UTF-8) and the final newline, like the real wc', () => { + // Real wc on this exact content: "6 22 143" (â and î are two bytes each). + const s = createInitialState(); + expect(out(s, 'wc documents/notes.txt')).toBe(' 6 22 143 documents/notes.txt'); + expect(out(s, 'wc -c documents/notes.txt')).toBe(' 143 documents/notes.txt'); + const e = build('linux', 'touch vide.txt', 'echo x > x.txt'); + expect(out(e, 'wc vide.txt')).toBe(' 0 0 0 vide.txt'); + expect(out(e, 'wc x.txt')).toBe(' 1 1 2 x.txt'); + // Real: printf 'pomme\npoire\n' | wc -> 2 2 12 + const p = build('linux', 'echo pomme > f', 'echo poire >> f'); + expect(out(p, 'cat f | wc')).toBe('2 2 12'); + }); + + it('ls sorts like ls (no directories-first) and marks nothing, -F adds the /', () => { + const s = build('linux', 'touch fichier.txt'); + expect(out(s, 'ls')).toBe('documents downloads fichier.txt projets'); + expect(out(s, 'ls -F')).toBe('documents/ downloads/ fichier.txt projets/'); + expect(out(createInitialState(), 'ls -a')).toBe('. .. .bashrc .profile .zshrc documents downloads projets'); + }); + + it('!! repeats the previous command (sudo !!)', () => { + const s = build('linux', 'apt update'); + const r = processCommand(s, 'sudo !!', 'linux'); + expect(r.lines[0]).toEqual({ text: 'sudo apt update', type: 'info' }); + expect(r.lines.some((l) => l.type === 'error')).toBe(false); + expect(r.newState.commandHistory[r.newState.commandHistory.length - 1]).toBe('sudo apt update'); + expect(out(createInitialState(), 'sudo !!')).toBe('bash: !!: event not found'); + }); + + it('apt needs root to change the system, sudo gives it', () => { + const s = createInitialState(); + expect(types(s, 'apt update')).toContain('error'); + expect(out(s, 'apt update')).toContain('are you root?'); + expect(types(s, 'sudo apt update')).not.toContain('error'); + expect(out(s, 'sudo apt install tree')).toContain('tree'); + expect(types(s, 'apt search tree')).not.toContain('error'); + expect(types(s, 'apt update', 'windows')).toContain('error'); + }); + + it('killall and Start-Process exist', () => { + const s = createInitialState(); + expect(processCommand(s, 'killall node', 'linux').lines).toEqual([]); + expect(types(s, 'killall')).toEqual(['error']); + expect(types(s, 'Start-Process powershell -Verb RunAs', 'windows')).toEqual(['info']); + expect(out(s, 'Start-Process powershell -Verb RunAs', 'windows')).toContain('administrateur'); + }); + + it('Linux commands are case-sensitive; the error repeats what was typed', () => { + const s = createInitialState(); + expect(types(s, 'LS')).toEqual(['error']); + expect(out(s, 'LS')).toMatch(/^LS: /); + // macOS (case-insensitive disk) and PowerShell accept any case. + expect(types(s, 'LS', 'macos')).not.toContain('error'); + expect(types(s, 'get-childitem', 'windows')).not.toContain('error'); + expect(out(s, 'Get-Locaton', 'windows')).toMatch(/^Get-Locaton: /); + }); + + it('rm -rf / hits the GNU safeguard and deletes nothing', () => { + const s = createInitialState(); + const r = processCommand(s, 'sudo rm -rf /', 'linux'); + expect(r.lines.map((l) => l.text)).toEqual([ + "rm: it is dangerous to operate recursively on '/'", + 'rm: use --no-preserve-root to override this failsafe', + ]); + expect(out(r.newState, 'ls')).toBe('documents downloads projets'); + }); + + it('ping answers in the Windows format under PowerShell', () => { + const w = out(createInitialState(), 'ping google.com', 'windows'); + expect(w).toMatch(/^Pinging google\.com \[142\.250\.74\.46\] with 32 bytes of data:/); + expect(w).toContain('Reply from 142.250.74.46: bytes=32'); + expect(out(createInitialState(), 'ping google.com')).toMatch(/^PING google\.com/); + }); +}); From a0c46348e07064423d85cec7dfc770f0717cbb2d Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:56:10 +0200 Subject: [PATCH 2/3] fix(terminal): sudo asks for the password once per session, like its cache (THI-353) The sudo lesson shows '[sudo] password for user:'; the simulator never did. The first sudo of a session now prints it (info line), later ones reuse the credential, as the real sudo does. Co-Authored-By: Claude Opus 5.5 --- src/app/data/commands/types.ts | 2 ++ src/app/data/terminalEngine.ts | 11 +++++++++++ src/test/terminalEngine.test.ts | 12 ++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/app/data/commands/types.ts b/src/app/data/commands/types.ts index e4d0251d..0b6bf4c4 100644 --- a/src/app/data/commands/types.ts +++ b/src/app/data/commands/types.ts @@ -49,6 +49,8 @@ export interface TerminalState { /** Directory before the last `cd` ($OLDPWD), for `cd -`. */ previousCwd?: string[]; commandHistory: string[]; + /** sudo already asked for the password in this session (its credential cache). */ + sudoAuthenticated?: boolean; user: string; hostname: string; envVars: Record; diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 05993d4c..6daa4832 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -740,6 +740,17 @@ function cmdSudo(args: string[], state: TerminalState, env: TerminalEnv): Comman if (args.length === 0) { return { lines: [{ text: 'usage: sudo command [args...]', type: 'error' }], newState: state }; } + // The first sudo of a session asks for the password; later ones reuse it (sudo's credential cache). + const result = cmdSudoRun(args, state, env); + if (state.sudoAuthenticated) return result; + return { + ...result, + lines: [{ text: `[sudo] password for ${state.user}: ****`, type: 'info' }, ...result.lines], + newState: { ...result.newState, sudoAuthenticated: true }, + }; +} + +function cmdSudoRun(args: string[], state: TerminalState, env: TerminalEnv): CommandOutput { if (args[0] === '-i' || args[0] === '-s') { return { lines: [{ text: `root@${state.hostname}:~# (session root simulée — tapez "exit" pour revenir)`, type: 'success' }], newState: state }; } diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 9e0a4a9e..3ec2f180 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -899,7 +899,8 @@ describe('sudo — privilege elevation', () => { it('sudo -i opens root shell', () => { const state = makeState(); const result = processCommand(state, 'sudo -i', 'linux'); - expect(result.lines[0].text).toContain('root'); + // The first sudo shows the password prompt before the root shell. + expect(result.lines.some((l) => l.text.includes('root@'))).toBe(true); }); it('sudo without args returns error', () => { @@ -2601,10 +2602,17 @@ describe('theory ↔ terminal: engine fidelity', () => { expect(out(s, 'Get-Locaton', 'windows')).toMatch(/^Get-Locaton: /); }); + it('sudo asks for the password the first time only, like its credential cache', () => { + const first = processCommand(createInitialState(), 'sudo whoami', 'linux'); + expect(first.lines[0]).toEqual({ text: '[sudo] password for user: ****', type: 'info' }); + const second = processCommand(first.newState, 'sudo whoami', 'linux'); + expect(second.lines.some((l) => l.text.startsWith('[sudo] password'))).toBe(false); + }); + it('rm -rf / hits the GNU safeguard and deletes nothing', () => { const s = createInitialState(); const r = processCommand(s, 'sudo rm -rf /', 'linux'); - expect(r.lines.map((l) => l.text)).toEqual([ + expect(r.lines.filter((l) => l.type === 'error').map((l) => l.text)).toEqual([ "rm: it is dangerous to operate recursively on '/'", 'rm: use --no-preserve-root to override this failsafe', ]); From 82bd528436c9fad93b32925dd40d1a9aabea6cee Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:05:27 +0200 Subject: [PATCH 3/3] fix(terminal): sudo whoami answers root (review follow-up, THI-353) The sudo exercise asks for 'sudo whoami to see that sudo gives you root'; the simulator answered 'user'. whoami now reports root while sudo runs. Also documents and tests cd - under PowerShell: Set-Location - exists since PowerShell 6.2 (the simulated shell is 7.x), silent like the real one. Co-Authored-By: Claude Opus 5.5 --- src/app/data/terminalEngine.ts | 6 ++++-- src/test/terminalEngine.test.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index 6daa4832..6cd5281d 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -362,7 +362,8 @@ function cmdLs(state: TerminalState, args: string[]): OutputLine[] { function cmdCd(state: TerminalState, args: string[], env: TerminalEnv = 'linux'): { lines: OutputLine[]; newCwd?: string[] } { const target = args[0]; if (target === '-') { - // `cd -` returns to $OLDPWD; bash prints where it lands, PowerShell stays silent. + // `cd -` returns to $OLDPWD; bash prints where it lands. PowerShell 6.2+ (the + // simulated 7.x) goes back in its location history silently; 5.1 did not have it. if (!state.previousCwd) { return { lines: env === 'windows' ? [] : [{ text: 'bash: cd: OLDPWD not set', type: 'error' }] }; } @@ -2044,7 +2045,8 @@ function runSimple(state: TerminalState, trimmed: string, env: TerminalEnv): Com } case 'whoami': - return { lines: [{ text: newState.user, type: 'output' }], newState }; + // Under sudo the command runs as root: that is what `sudo whoami` demonstrates. + return { lines: [{ text: runningAsRoot ? 'root' : newState.user, type: 'output' }], newState }; case 'hostname': return { lines: [{ text: newState.hostname, type: 'output' }], newState }; diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 3ec2f180..c90db85e 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -2609,6 +2609,21 @@ describe('theory ↔ terminal: engine fidelity', () => { expect(second.lines.some((l) => l.text.startsWith('[sudo] password'))).toBe(false); }); + it('sudo whoami answers root — the point of the sudo exercise', () => { + const s = createInitialState(); + expect(out(s, 'whoami')).toBe('user'); + const r = processCommand(s, 'sudo whoami', 'linux'); + expect(r.lines.filter((l) => l.type === 'output').map((l) => l.text)).toEqual(['root']); + expect(out(r.newState, 'whoami')).toBe('user'); + }); + + it('PowerShell 7 goes back with cd - / Set-Location -, silently', () => { + const s = build('windows', 'Set-Location documents', 'Set-Location ..'); + const r = processCommand(s, 'Set-Location -', 'windows'); + expect(r.lines).toEqual([]); + expect(r.newState.cwd).toEqual(['home', 'user', 'documents']); + }); + it('rm -rf / hits the GNU safeguard and deletes nothing', () => { const s = createInitialState(); const r = processCommand(s, 'sudo rm -rf /', 'linux');