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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/components/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.`, ``]
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/TerminalEmulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/app/data/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface TerminalState {
hostname: string;
envVars: Record<string, string>;
git?: GitState;
/** PowerShell execution policy set by Set-ExecutionPolicy (absent = Windows default, Restricted). */
executionPolicy?: string;
}

export interface CommandOutput {
Expand Down
34 changes: 34 additions & 0 deletions src/app/data/commands/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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': {
Expand Down
3 changes: 2 additions & 1 deletion src/app/data/curriculum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1700,6 +1700,7 @@ export const curriculum: Module[] = [
windows: 'Tapez: cat $PROFILE',
},
validate: validateShellConfig,
setup: powershellProfile,
successMessage: 'Bien joué ! Voici votre configuration shell actuelle.',
},
},
Expand Down
30 changes: 29 additions & 1 deletion src/app/data/lessonSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<'linux' | 'macos' | 'windows', string>>;
}

// Fixed hashes and dates keep the prepared history deterministic (tests, and
Expand Down Expand Up @@ -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) => ({
Expand Down
159 changes: 143 additions & 16 deletions src/app/data/terminalEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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<string, number> = { u: 0, g: 3, o: 6 };
const index: Record<string, number> = { 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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,
Expand Down
9 changes: 0 additions & 9 deletions src/test/lessonFidelity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,6 @@ const KNOWN_DESYNCS = new Set<string>([
'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();
Expand Down
10 changes: 9 additions & 1 deletion src/test/lessonSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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/);
});
});
Loading
Loading