From 39d70b8c547ab22e5ef0945717679a9a83994c66 Mon Sep 17 00:00:00 2001 From: "Thierry V." <46031203+thierryvm@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:55:38 +0200 Subject: [PATCH] fix(terminal): run scripts, $PROFILE, execution policy and chmod modes (THI-353) Six lesson x environment cases validated while the terminal printed an error. They now run for real: - Scripts: `./script.sh`, `bash|sh|zsh script.sh`, and `.\script.sh` on Windows execute the file line by line. `./x` needs the execute bit, as the chmod lesson teaches ("Permission denied" otherwise). A script runs in a child shell: the files it writes stay, its cd and exported variables do not, and its lines stay out of the history. A depth limit stops a script that calls itself. - PowerShell: `$PROFILE` expands to the profile path, and the configuration lesson now starts with a real profile file, so `cat $PROFILE` shows it (the setup note is Windows-only, via the new `noteByEnv`). `(Get-Content f).Count` counts lines. `Set-ExecutionPolicy` records the policy that `Get-ExecutionPolicy` reports; both are PowerShell-only. - chmod: `+x` wrote the bits one position too far (`-rw-r--r--` became `-rw-xr-xr-x`, the owner still could not run the file), and only six octal modes were known. It now handles any 3-digit octal mode and symbolic modes (`u+x`, `go-w`, `a=r`, comma lists), and rejects invalid ones. Found by the new script tests. Code-review follow-ups in the same change: `clear` inside a script now clears the screen (and keeps what the script prints after it); a shared budget of 500 script lines per command bounds fan-out (N lines each calling an N-line script is N^3 runs within the depth limit); `(Get-Content $PROFILE).Count` expands $PROFILE; pipes run their left side in the session environment instead of a hard-coded 'linux'. Not in this change: `./script.sh > out.txt` still ignores the redirection, like every command except echo today. That is the next THI-353 step (generic redirection), together with the 3 `2>` cases left in the ratchet. The THI-353 ratchet shrinks from 9 to 3 known cases. Co-Authored-By: Claude Opus 5.5 --- src/app/components/LessonPage.tsx | 3 +- src/app/components/TerminalEmulator.tsx | 7 +- src/app/data/commands/types.ts | 2 + src/app/data/commands/windows.ts | 34 +++++ src/app/data/curriculum.ts | 3 +- src/app/data/lessonSetup.ts | 30 +++- src/app/data/terminalEngine.ts | 159 +++++++++++++++++--- src/test/lessonFidelity.test.ts | 9 -- src/test/lessonSetup.test.ts | 10 +- src/test/terminalEngine.test.ts | 183 ++++++++++++++++++++++++ 10 files changed, 410 insertions(+), 30 deletions(-) diff --git a/src/app/components/LessonPage.tsx b/src/app/components/LessonPage.tsx index 8c7544ae..a5f7e51b 100644 --- a/src/app/components/LessonPage.tsx +++ b/src/app/components/LessonPage.tsx @@ -213,7 +213,8 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: { const effectiveInstruction = lesson.exercise?.instructionByEnv?.[selectedEnv] ?? lesson.exercise?.instruction ?? ''; const setup = lesson.exercise?.setup; - const setupNote = setup ? [stripInlineMarkdown(setup.note)] : []; + const setupNoteText = setup?.noteByEnv?.[selectedEnv] ?? setup?.note; + const setupNote = setupNoteText ? [stripInlineMarkdown(setupNoteText)] : []; const welcomeMessage = lesson.exercise ? exerciseCompleted ? [`πŸ“š ${lesson.title}`, ``, ...setupNote, `βœ“ Exercice dΓ©jΓ  complΓ©tΓ© β€” Β« Suivant Β» pour continuer, ou pratique librement ci-dessous.`, ``] diff --git a/src/app/components/TerminalEmulator.tsx b/src/app/components/TerminalEmulator.tsx index fc692a74..aa6be9a9 100644 --- a/src/app/components/TerminalEmulator.tsx +++ b/src/app/components/TerminalEmulator.tsx @@ -212,7 +212,12 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us const result = processCommand(activeState, trimmed, environment); if (result.clear) { - setLines([]); + // A script can clear the screen and then print: keep what came after. + setLines(result.lines.map((l: OutputLine) => ({ + id: nextId(), + type: l.type === 'error' ? 'error' as const : l.type === 'success' ? 'success' as const : 'output' as const, + text: l.text, + }))); setTermState(result.newState); setInput(''); setHistoryIndex(-1); diff --git a/src/app/data/commands/types.ts b/src/app/data/commands/types.ts index 778328f7..3b72777d 100644 --- a/src/app/data/commands/types.ts +++ b/src/app/data/commands/types.ts @@ -51,6 +51,8 @@ export interface TerminalState { hostname: string; envVars: Record; git?: GitState; + /** PowerShell execution policy set by Set-ExecutionPolicy (absent = Windows default, Restricted). */ + executionPolicy?: string; } export interface CommandOutput { diff --git a/src/app/data/commands/windows.ts b/src/app/data/commands/windows.ts index eb5e5465..7f5d9d24 100644 --- a/src/app/data/commands/windows.ts +++ b/src/app/data/commands/windows.ts @@ -15,6 +15,8 @@ export interface WindowsCmdDeps { cmdEnv: (state: TerminalState) => OutputLine[]; } +const EXECUTION_POLICIES = ['Restricted', 'AllSigned', 'RemoteSigned', 'Unrestricted', 'Bypass', 'Undefined', 'Default']; + /** * Handles PowerShell aliases and Windows/macOS-specific commands. * Returns null if the command is not handled by this module (caller falls through to default). @@ -165,6 +167,38 @@ export function handleWindows( return { lines, newState }; } + // ── script execution policy (PowerShell only) ───────────────────────────── + case 'set-executionpolicy': { + if (env !== 'windows') return null; + let value: string | undefined; + let scope = 'LocalMachine'; + for (let i = 0; i < args.length; i++) { + const a = args[i].toLowerCase(); + if (a === '-executionpolicy') value = args[++i]; + else if (a === '-scope') scope = args[++i] ?? scope; + else if (!a.startsWith('-') && value === undefined) value = args[i]; + } + if (!value) { + return { lines: [{ text: 'Set-ExecutionPolicy: indiquez une politique, par exemple Set-ExecutionPolicy RemoteSigned', type: 'error' }], newState }; + } + const policy = EXECUTION_POLICIES.find((p) => p.toLowerCase() === value!.toLowerCase()); + if (!policy) { + return { + lines: [{ text: `Set-ExecutionPolicy: Cannot bind parameter 'ExecutionPolicy'. Cannot convert value "${value}" to type "Microsoft.PowerShell.ExecutionPolicy".`, type: 'error' }], + newState, + }; + } + // Real PowerShell prints nothing on success; the info line says what changed. + return { + lines: [{ text: `Politique d'exΓ©cution dΓ©finie sur ${policy} (portΓ©e ${scope}). VΓ©rifiez avec Get-ExecutionPolicy.`, type: 'info' }], + newState: { ...newState, executionPolicy: policy }, + }; + } + + case 'get-executionpolicy': + if (env !== 'windows') return null; + return { lines: [{ text: newState.executionPolicy ?? 'Restricted', type: 'output' }], newState }; + // ── permissions ─────────────────────────────────────────────────────────── case 'get-acl': case 'icacls': { diff --git a/src/app/data/curriculum.ts b/src/app/data/curriculum.ts index 6e745449..742fe14e 100644 --- a/src/app/data/curriculum.ts +++ b/src/app/data/curriculum.ts @@ -14,7 +14,7 @@ import { validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow, } from './validators'; import { - gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, sshDirectory, + gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, powershellProfile, sshDirectory, type LessonSetup, } from './lessonSetup'; export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info'; @@ -1700,6 +1700,7 @@ export const curriculum: Module[] = [ windows: 'Tapez: cat $PROFILE', }, validate: validateShellConfig, + setup: powershellProfile, successMessage: 'Bien jouΓ© ! Voici votre configuration shell actuelle.', }, }, diff --git a/src/app/data/lessonSetup.ts b/src/app/data/lessonSetup.ts index b2768207..dd5f87d9 100644 --- a/src/app/data/lessonSetup.ts +++ b/src/app/data/lessonSetup.ts @@ -18,8 +18,10 @@ import type { DirectoryNode, FSNode, GitCommit, GitState, TerminalState } from ' export interface LessonSetup { /** Pure: returns a new state, never mutates the one it receives. */ apply: (state: TerminalState) => TerminalState; - /** Shown in the welcome message, e.g. "DΓ©pΓ΄t Git prΓͺt dans ~/projets". */ + /** Shown in the welcome message, e.g. "DΓ©pΓ΄t Git prΓͺt dans ~/projets". Empty = no line. */ note: string; + /** Per-environment override of `note`, like `instructionByEnv` on an exercise. */ + noteByEnv?: Partial>; } // Fixed hashes and dates keep the prepared history deterministic (tests, and @@ -92,6 +94,32 @@ export const gitRepoWithRemote: LessonSetup = { note: 'DΓ©pΓ΄t Git prΓͺt dans ~/projets (1 commit, remote origin configurΓ©).', }; +/** + * The PowerShell profile that `$PROFILE` points to (see terminalEngine), so + * `cat $PROFILE` shows a real profile instead of "file not found". It lives in + * ~/documents, the simulated C:\Users\user\Documents; on Linux and macOS it is + * just a folder the lesson never mentions. + */ +export const powershellProfile: LessonSetup = { + apply: (s) => ({ + ...s, + root: withNode(s.root, ['home', 'user', 'documents', 'PowerShell'], { + type: 'directory', + permissions: 'drwxr-xr-x', + owner: 'user', + group: 'user', + children: { + 'Microsoft.PowerShell_profile.ps1': file( + '# Profil PowerShell β€” chargΓ© Γ  chaque ouverture de PowerShell\nSet-Alias ll Get-ChildItem\n$env:EDITOR = "code"\nfunction gs { git status }', + '-rw-r--r--', + ), + }, + }), + }), + note: '', + noteByEnv: { windows: 'Votre profil PowerShell ($PROFILE) existe dΓ©jΓ .' }, +}; + /** A `~/.ssh` directory with the permissions a correct setup has (700 / 600 / 644). */ export const sshDirectory: LessonSetup = { apply: (s) => ({ diff --git a/src/app/data/terminalEngine.ts b/src/app/data/terminalEngine.ts index e0c0628c..b3ec5a56 100644 --- a/src/app/data/terminalEngine.ts +++ b/src/app/data/terminalEngine.ts @@ -643,21 +643,44 @@ function cmdChmod(state: TerminalState, args: string[]): { lines: OutputLine[]; const node = getNode(newRoot, resolved); if (!node) return { lines: [{ text: `chmod: cannot access '${filePath}': No such file or directory`, type: 'error' }] }; - // Apply permission change (simplified) - const permsMap: Record = { - '755': 'rwxr-xr-x', '644': 'rw-r--r--', '600': 'rw-------', - '777': 'rwxrwxrwx', '700': 'rwx------', '444': 'r--r--r--', - }; - const prefix = node.type === 'directory' ? 'd' : '-'; - if (permsMap[mode]) { - node.permissions = prefix + permsMap[mode]; - } else if (mode.includes('+x')) { - const cur = node.permissions; - node.permissions = cur.slice(0, 4) + 'x' + cur.slice(5, 7) + 'x' + cur.slice(8, 10) + 'x'; - } + const next = applyChmodMode(node.permissions, mode); + if (!next) return { lines: [{ text: `chmod: invalid mode: '${mode}'`, type: 'error' }] }; + node.permissions = next; return { lines: [{ text: `Mode de '${filePath}' changΓ©`, type: 'success' }], newRoot }; } +/** + * Applies a chmod mode to a `ls -l` permission string (`-rw-r--r--`). + * Octal: `755`, `640`… Symbolic: `+x`, `u+x`, `go-w`, `a=r`, comma lists. + * Returns null for an invalid mode. (The previous version only knew six octal + * values, and `+x` wrote the bits one position too far: `-rw-r--r--` became + * `-rw-xr-xr-x`, so the owner still could not run the script.) + */ +function applyChmodMode(current: string, mode: string): string | null { + const type = current[0]; + if (/^[0-7]{3}$/.test(mode)) { + const bits = mode.split('').map((d) => { + const n = Number(d); + return (n & 4 ? 'r' : '-') + (n & 2 ? 'w' : '-') + (n & 1 ? 'x' : '-'); + }); + return type + bits.join(''); + } + const perms = current.slice(1).split(''); // 9 chars: owner, group, other + const offset: Record = { u: 0, g: 3, o: 6 }; + const index: Record = { r: 0, w: 1, x: 2 }; + for (const clause of mode.split(',')) { + const m = clause.match(/^([ugoa]*)([+\-=])([rwx]*)$/); + if (!m) return null; + const [, whoRaw, op, what] = m; + const who = !whoRaw || whoRaw.includes('a') ? ['u', 'g', 'o'] : [...new Set(whoRaw.split(''))]; + for (const w of who) { + if (op === '=') for (const p of 'rwx') perms[offset[w] + index[p]] = '-'; + for (const p of what) perms[offset[w] + index[p]] = op === '-' ? '-' : p; + } + } + return type + perms.join(''); +} + // ─── Environment Variable Commands ─────────────────────────────────────────── // cmdExport, cmdEnv, cmdPrintenv, cmdSource β†’ moved to ./commands/env.ts @@ -761,9 +784,90 @@ function cmdEchoRedirect( return { lines: [], newRoot }; } -function cmdPipe(state: TerminalState, left: string, right: string): OutputLine[] { +// ─── Scripts ────────────────────────────────────────────────────────────────── + +/** PowerShell's $PROFILE for the simulated user (C:\Users\user = ~ in this filesystem). */ +const PS_PROFILE_PATH = '~/documents/PowerShell/Microsoft.PowerShell_profile.ps1'; +const PS_PROFILE_DISPLAY = 'C:\\Users\\user\\documents\\PowerShell\\Microsoft.PowerShell_profile.ps1'; + +/** + * A script that calls itself (or two that call each other) must not hang the + * tab. Depth alone is not enough: N lines each calling an N-line script is NΒ³ + * runs within the depth limit, so the whole call tree also shares a line budget. + */ +const MAX_SCRIPT_DEPTH = 3; +const MAX_SCRIPT_LINES = 500; +let scriptDepth = 0; +let scriptLinesRun = 0; +let scriptBudgetHit = false; + +interface ScriptCall { + /** As typed, for error messages (`./script.sh`, `.\script.sh`). */ + invoked: string; + file: string; + /** `./x` needs the execute bit; `bash x` does not β€” the lesson on chmod relies on it. */ + requireExec: boolean; +} + +function scriptCall(parts: string[], env: TerminalEnv): ScriptCall | null { + const [first = '', second] = parts; + if (/^\.[\\/]./.test(first)) { + return { invoked: first, file: first.slice(2).replace(/\\/g, '/'), requireExec: env !== 'windows' }; + } + if (['bash', 'sh', 'zsh'].includes(first.toLowerCase()) && second && !second.startsWith('-')) { + return { invoked: second, file: second.replace(/\\/g, '/'), requireExec: false }; + } + return null; +} + +function runScript(state: TerminalState, call: ScriptCall, env: TerminalEnv): CommandOutput { + const prefix = env === 'windows' ? '' : 'bash: '; + const fail = (reason: string): CommandOutput => ({ + lines: [{ text: `${prefix}${call.invoked}: ${reason}`, type: 'error' }], + newState: state, + }); + const node = getNode(state.root, resolvePath(state, call.file)); + if (!node) return fail('No such file or directory'); + if (node.type === 'directory') return fail('Is a directory'); + if (call.requireExec && node.permissions[3] !== 'x') return fail('Permission denied'); + if (scriptDepth >= MAX_SCRIPT_DEPTH) return fail('trop de scripts imbriquΓ©s (limite du simulateur)'); + + if (scriptDepth === 0) { + scriptLinesRun = 0; + scriptBudgetHit = false; + } + scriptDepth++; + try { + let s = state; + let lines: OutputLine[] = []; + let cleared = false; + for (const raw of node.content.split('\n')) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; // comments and the #! shebang + if (scriptBudgetHit) break; + if (++scriptLinesRun > MAX_SCRIPT_LINES) { + scriptBudgetHit = true; + lines = [...lines, { text: `${prefix}${call.invoked}: script interrompu aprΓ¨s ${MAX_SCRIPT_LINES} lignes exΓ©cutΓ©es (limite du simulateur)`, type: 'error' }]; + break; + } + const out = processCommand(s, line, env); + if (out.clear) cleared = true; + // `clear` wipes what the script printed so far; later lines still show. + lines = out.clear ? [...out.lines] : [...lines, ...out.lines]; + s = out.newState; + } + // A script runs in a child shell: the files it writes stay, but its `cd` + // and the variables it exports vanish with it (the difference with + // `source`), and its lines never enter the caller's history. + return { lines, clear: cleared || undefined, newState: { ...state, root: s.root, git: s.git } }; + } finally { + scriptDepth--; + } +} + +function cmdPipe(state: TerminalState, left: string, right: string, env: TerminalEnv): OutputLine[] { // Execute left side and feed output to right side's stdin - const leftResult = processCommand(state, left, 'linux'); + const leftResult = processCommand(state, left, env); const inputText = leftResult.lines.filter((l) => l.type === 'output').map((l) => l.text).join('\n'); // Simulate stdin for right command @@ -1302,7 +1406,7 @@ export function processCommand(state: TerminalState, input: string, env: Termina if (trimmed.includes('|')) { const [left, ...rest] = trimmed.split('|'); const right = rest.join('|'); - const pipeLines = cmdPipe(state, left.trim(), right.trim()); + const pipeLines = cmdPipe(state, left.trim(), right.trim(), env); return { lines: pipeLines, newState }; } @@ -1353,11 +1457,34 @@ export function processCommand(state: TerminalState, input: string, env: Termina }; } + if (env === 'windows') { + // `$PROFILE` alone prints the path of the profile script, like PowerShell. + if (/^\$profile$/i.test(trimmed)) { + return { lines: [{ text: PS_PROFILE_DISPLAY, type: 'output' }], newState }; + } + // `(Get-Content file).Count` β€” number of lines, the PowerShell `wc -l`. + const psCount = trimmed.match(/^\(\s*(?:get-content|gc|cat)\s+(.+?)\s*\)\.count$/i); + if (psCount) { + const target = parseArgs(psCount[1]).map((p) => (/^\$profile$/i.test(p) ? PS_PROFILE_PATH : p)); + const out = cmdCat(newState, target); + const errors = out.filter((l) => l.type === 'error'); + return { lines: errors.length ? errors : [{ text: String(out.length), type: 'output' }], newState }; + } + } + // Parse command - const parts = parseArgs(trimmed); + let parts = parseArgs(trimmed); const cmd = parts[0]?.toLowerCase(); + if (env === 'windows') { + // `$PROFILE` as an argument: a readable path for echo, the file itself otherwise. + const shown = ['echo', 'write-output', 'write-host'].includes(cmd ?? ''); + parts = parts.map((p) => (/^\$profile$/i.test(p) ? (shown ? PS_PROFILE_DISPLAY : PS_PROFILE_PATH) : p)); + } const args = parts.slice(1); + const script = scriptCall(parts, env); + if (script) return runScript(newState, script, env); + // Dependencies for Windows/macOS alias handler const winDeps: WindowsCmdDeps = { cmdPwd, cmdCd, cmdLs, cmdCat, cmdMkdir, cmdTouch, diff --git a/src/test/lessonFidelity.test.ts b/src/test/lessonFidelity.test.ts index 2e06a0b1..133b8396 100644 --- a/src/test/lessonFidelity.test.ts +++ b/src/test/lessonFidelity.test.ts @@ -27,15 +27,6 @@ const KNOWN_DESYNCS = new Set([ 'redirection/stderr [linux]', 'redirection/stderr [macos]', 'redirection/stderr [windows]', // + Get-Item is not simulated - // Scripts: `./script.sh` and `.\script.sh` are not executed. - 'variables/scripts [linux]', - 'variables/scripts [macos]', - 'variables/scripts [windows]', - // $PROFILE is not expanded, the file does not exist. - 'variables/shell-config [windows]', - // PowerShell cmdlets / expressions not simulated. - 'lecture/wc [windows]', // (Get-Content …).Count - 'permissions/chmod [windows]', // Set-ExecutionPolicy ]); const norm = (s: string) => s.replace(/\s+/g, ' ').trim(); diff --git a/src/test/lessonSetup.test.ts b/src/test/lessonSetup.test.ts index 0d2aedc3..611f8537 100644 --- a/src/test/lessonSetup.test.ts +++ b/src/test/lessonSetup.test.ts @@ -5,12 +5,13 @@ import { gitRepoWithBranch, gitRepoWithCommit, gitRepoWithRemote, + powershellProfile, sshDirectory, } from '../app/data/lessonSetup'; describe('lesson setups', () => { it('never mutate the state they receive', () => { - for (const setup of [gitRepoEmpty, gitRepoWithCommit, gitRepoWithRemote, gitRepoWithBranch('feature/x'), sshDirectory]) { + for (const setup of [gitRepoEmpty, gitRepoWithCommit, gitRepoWithRemote, gitRepoWithBranch('feature/x'), sshDirectory, powershellProfile]) { const base = createInitialState(); const snapshot = JSON.stringify(base); setup.apply(base); @@ -50,4 +51,11 @@ describe('lesson setups', () => { it('leave the default state untouched for lessons without a setup', () => { expect(processCommand(createInitialState(), 'ls -la ~/.ssh', 'linux').lines[0].type).toBe('error'); }); + + it('put the PowerShell profile where $PROFILE points (Windows)', () => { + const s = powershellProfile.apply(createInitialState()); + const out = processCommand(s, 'cat $PROFILE', 'windows').lines; + expect(out.every((l) => l.type !== 'error')).toBe(true); + expect(out[0].text).toMatch(/Profil PowerShell/); + }); }); diff --git a/src/test/terminalEngine.test.ts b/src/test/terminalEngine.test.ts index 6ea16ea6..0b4f6ea1 100644 --- a/src/test/terminalEngine.test.ts +++ b/src/test/terminalEngine.test.ts @@ -2240,3 +2240,186 @@ describe('ai-help', () => { } }); }); + +// ─── Scripts, $PROFILE and PowerShell expressions (THI-353) ────────────────── + +describe('running a script', () => { + /** Runs commands in sequence from the default filesystem, returns the last output and state. */ + function run(env: 'linux' | 'macos' | 'windows', ...cmds: string[]) { + let state = createInitialState(); + let last = processCommand(state, 'pwd', env); + for (const c of cmds) { + last = processCommand(state, c, env); + state = last.newState; + } + return { out: last.lines, state }; + } + const texts = (lines: { text: string }[]) => lines.map((l) => l.text); + + it('./script.sh runs the lines of the script', () => { + const { out } = run('linux', 'cd projets', './script.sh'); + expect(texts(out)).toEqual(['Bonjour le monde !', 'Ce script fonctionne !']); + }); + + it('bash runs it without the execute bit, from any directory', () => { + expect(texts(run('linux', 'bash projets/script.sh').out)).toEqual(['Bonjour le monde !', 'Ce script fonctionne !']); + expect(texts(run('macos', 'cd projets', 'sh script.sh').out)).toHaveLength(2); + }); + + it('.\\script.sh runs on Windows, where there is no execute bit', () => { + const { out } = run('windows', 'cd projets', '.\\script.sh'); + expect(out.every((l) => l.type !== 'error')).toBe(true); + expect(texts(out)).toContain('Bonjour le monde !'); + }); + + it('./file without the execute bit is refused, as chmod teaches', () => { + const denied = run('linux', 'cd projets', './README.md').out; + expect(denied).toEqual([{ text: 'bash: ./README.md: Permission denied', type: 'error' }]); + const allowed = run('linux', 'cd projets', 'chmod +x README.md', './README.md').out; + expect(allowed.some((l) => /Permission denied/.test(l.text))).toBe(false); + }); + + it('reports a missing script or a directory', () => { + expect(run('linux', './absent.sh').out[0].text).toBe('bash: ./absent.sh: No such file or directory'); + expect(run('linux', 'bash projets').out[0].text).toBe('bash: projets: Is a directory'); + }); + + it('runs in a child shell: files stay, cd and exported variables do not', () => { + const { state } = run( + 'linux', + 'echo "cd /tmp" > s.sh', + 'echo "export MARK=1" >> s.sh', + 'echo "touch trace.txt" >> s.sh', + 'bash s.sh', + ); + expect(state.cwd).toEqual(['home', 'user']); + expect(state.envVars.MARK).toBeUndefined(); + expect(processCommand(state, 'ls /tmp', 'linux').lines[0].text).toContain('trace.txt'); + }); + + it('keeps only the invocation in the history', () => { + const { state } = run('linux', 'cd projets', './script.sh'); + expect(state.commandHistory).toEqual(['cd projets', './script.sh']); + }); + + it('stops a script that calls itself instead of hanging', () => { + const { out } = run('linux', 'echo "bash loop.sh" > loop.sh', 'bash loop.sh'); + expect(out.some((l) => l.type === 'error' && /imbriquΓ©s/.test(l.text))).toBe(true); + }); +}); + +describe('PowerShell: $PROFILE, (Get-Content).Count, execution policy', () => { + const s = () => createInitialState(); + + it('$PROFILE prints the profile path on Windows', () => { + expect(processCommand(s(), '$PROFILE', 'windows').lines[0].text).toBe( + 'C:\\Users\\user\\documents\\PowerShell\\Microsoft.PowerShell_profile.ps1', + ); + expect(processCommand(s(), 'echo $PROFILE', 'windows').lines[0].text).toMatch(/Microsoft\.PowerShell_profile\.ps1$/); + }); + + it('$PROFILE means nothing in bash', () => { + expect(processCommand(s(), 'cat $PROFILE', 'linux').lines[0].type).toBe('error'); + }); + + it('(Get-Content file).Count counts the lines', () => { + const lines = processCommand(s(), 'cat documents/rapport.md', 'linux').lines.length; + expect(processCommand(s(), '(Get-Content documents/rapport.md).Count', 'windows').lines).toEqual([ + { text: String(lines), type: 'output' }, + ]); + expect(processCommand(s(), '(gc documents/absent.md).Count', 'windows').lines[0].type).toBe('error'); + }); + + it('Set-ExecutionPolicy changes what Get-ExecutionPolicy reports', () => { + expect(processCommand(s(), 'Get-ExecutionPolicy', 'windows').lines[0].text).toBe('Restricted'); + const set = processCommand(s(), 'Set-ExecutionPolicy RemoteSigned -Scope CurrentUser', 'windows'); + expect(set.lines[0].type).toBe('info'); + expect(set.lines[0].text).toContain('CurrentUser'); + expect(processCommand(set.newState, 'Get-ExecutionPolicy', 'windows').lines[0].text).toBe('RemoteSigned'); + expect(processCommand(s(), 'Set-ExecutionPolicy -ExecutionPolicy bypass', 'windows').newState.executionPolicy).toBe('Bypass'); + }); + + it('Set-ExecutionPolicy rejects a missing or unknown policy', () => { + expect(processCommand(s(), 'Set-ExecutionPolicy', 'windows').lines[0].type).toBe('error'); + expect(processCommand(s(), 'Set-ExecutionPolicy Whatever', 'windows').lines[0].text).toMatch(/Cannot convert value "Whatever"/); + }); + + it('the execution policy cmdlets do not exist in bash', () => { + expect(processCommand(s(), 'Set-ExecutionPolicy RemoteSigned', 'linux').lines[0].text).toMatch(/commande introuvable/); + }); +}); + +describe('chmod modes (THI-353)', () => { + function modeAfter(mode: string, file = 'projets/README.md') { + const s = processCommand(createInitialState(), `chmod ${mode} ${file}`, 'linux').newState; + const line = processCommand(s, `ls -l ${file}`, 'linux').lines[0].text; + return line.slice(0, 10); + } + + it('+x makes the file executable for everyone (was shifted one position)', () => { + expect(modeAfter('+x')).toBe('-rwxr-xr-x'); + }); + + it('u+x only for the owner, go-w removes write, a=r sets exactly read', () => { + expect(modeAfter('u+x')).toBe('-rwxr--r--'); + expect(modeAfter('777')).toBe('-rwxrwxrwx'); + expect(modeAfter('go-w', 'projets/script.sh')).toBe('-rwxr-xr-x'); + expect(modeAfter('a=r')).toBe('-r--r--r--'); + expect(modeAfter('u+x,g+w')).toBe('-rwxrw-r--'); + }); + + it('accepts every octal mode, not only a fixed list', () => { + expect(modeAfter('750')).toBe('-rwxr-x---'); + expect(modeAfter('640')).toBe('-rw-r-----'); + }); + + it('rejects an invalid mode without changing the file', () => { + const r = processCommand(createInitialState(), 'chmod 9z9 projets/README.md', 'linux'); + expect(r.lines[0]).toEqual({ text: "chmod: invalid mode: '9z9'", type: 'error' }); + expect(modeAfter('+q')).toBe('-rw-r--r--'); + }); + + it('keeps the directory marker', () => { + const s = processCommand(createInitialState(), 'chmod 700 documents', 'linux').newState; + const line = processCommand(s, 'ls -l', 'linux').lines.find((l) => l.text.endsWith(' documents')); + expect(line?.text.slice(0, 10)).toBe('drwx------'); + }); +}); + +describe('scripts β€” review follow-ups (THI-353)', () => { + function build(env: 'linux' | 'windows', ...cmds: string[]) { + let state = createInitialState(); + for (const c of cmds) state = processCommand(state, c, env).newState; + return state; + } + + it('clear inside a script clears the screen and keeps what follows', () => { + const s = build('linux', 'echo "echo avant" > c.sh', 'echo "clear" >> c.sh', 'echo "echo aprΓ¨s" >> c.sh'); + const r = processCommand(s, 'bash c.sh', 'linux'); + expect(r.clear).toBe(true); + expect(r.lines.map((l) => l.text)).toEqual(['aprΓ¨s']); + }); + + it('(Get-Content $PROFILE).Count counts the profile lines', () => { + const s = build('windows', 'mkdir -p documents/PowerShell', 'echo "ligne 1" > documents/PowerShell/Microsoft.PowerShell_profile.ps1', 'echo "ligne 2" >> documents/PowerShell/Microsoft.PowerShell_profile.ps1'); + expect(processCommand(s, '(Get-Content $PROFILE).Count', 'windows').lines).toEqual([{ text: '2', type: 'output' }]); + }); + + it('a fan-out of scripts is cut by the shared line budget, quickly', () => { + // 30 lines, each calling the same 30-line script, 3 levels deep = 27 000 runs without a budget. + let s = build('linux', 'echo "bash big.sh" > big.sh'); + for (let i = 0; i < 29; i++) s = processCommand(s, 'echo "bash big.sh" >> big.sh', 'linux').newState; + const t0 = performance.now(); + const r = processCommand(s, 'bash big.sh', 'linux'); + expect(performance.now() - t0).toBeLessThan(2000); + expect(r.lines.filter((l) => /script interrompu aprΓ¨s 500 lignes/.test(l.text))).toHaveLength(1); + // The budget resets for the next command. + expect(processCommand(r.newState, 'bash projets/script.sh', 'linux').lines[0].text).toBe('Bonjour le monde !'); + }); + + it('a pipe keeps the environment: a Windows script needs no execute bit there either', () => { + const s = build('windows', 'echo "echo trouvΓ©" > outil.sh'); + const r = processCommand(s, '.\\outil.sh | grep trouvΓ©', 'windows'); + expect(r.lines).toEqual([{ text: 'trouvΓ©', type: 'output' }]); + }); +});