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');