// Terminal élève Syllabus : xterm.js + un petit shell simulé (vitrine et lab d'essai). // En production, le terminal se branche sur le proxy WebSocket de la console série Nova. (function () { var THEME = { background: '#13233A', foreground: '#E8EDF5', cursor: '#FFD23F', cursorAccent: '#13233A', selectionBackground: '#2B4A73', black: '#0D192B', red: '#FF7A66', green: '#7CF2C0', yellow: '#FFD23F', blue: '#8DB4FF', magenta: '#FF6FB5', cyan: '#7CE3F2', white: '#E8EDF5', brightBlack: '#6B7F99', brightRed: '#FF9A8A', brightGreen: '#7CF2C0', brightYellow: '#FFD23F', brightBlue: '#A9C6FF', brightMagenta: '#FF8FC6', brightCyan: '#9CEBF7', brightWhite: '#FFFFFF' }; var G = '\x1b[1;92m', B = '\x1b[1;94m', Y = '\x1b[93m', R = '\x1b[91m', D = '\x1b[2m', X = '\x1b[0m'; function Shell(term, o) { this.t = term; this.o = o; this.user = o.user || 'eleve'; this.host = o.host || 'debian-lab'; this.home = '/home/' + this.user; this.cwd = this.home; this.dirs = new Set(['/', '/home', this.home, '/etc', '/tmp', '/var', '/var/log']); this.files = new Map([ [this.home + '/.bashrc', '# ~/.bashrc'], ['/etc/hostname', this.host], ['/etc/os-release', 'PRETTY_NAME="Debian GNU/Linux 13 (trixie)"\nID=debian'] ]); this.line = ''; this.hist = []; this.hi = 0; this.busy = false; } Shell.prototype.short = function (p) { return p === this.home ? '~' : p.indexOf(this.home + '/') === 0 ? '~' + p.slice(this.home.length) : p; }; Shell.prototype.prompt = function () { this.t.write(G + this.user + '@' + this.host + X + ':' + B + this.short(this.cwd) + X + '$ '); }; Shell.prototype.path = function (p) { if (!p || p === '~') return this.home; if (p.indexOf('~/') === 0) p = this.home + p.slice(1); var parts = (p[0] === '/' ? p : this.cwd + '/' + p).split('/'), out = []; parts.forEach(function (s) { if (!s || s === '.') return; if (s === '..') out.pop(); else out.push(s); }); return '/' + out.join('/'); }; Shell.prototype.parent = function (p) { var i = p.lastIndexOf('/'); return i <= 0 ? '/' : p.slice(0, i); }; Shell.prototype.base = function (p) { return p.slice(p.lastIndexOf('/') + 1); }; Shell.prototype.children = function (d) { var self = this, out = []; this.dirs.forEach(function (x) { if (x !== '/' && self.parent(x) === d) out.push({ n: self.base(x), dir: true }); }); this.files.forEach(function (v, x) { if (self.parent(x) === d) out.push({ n: self.base(x), dir: false, size: v.length }); }); return out.sort(function (a, b) { return a.n.replace(/^\./, '').localeCompare(b.n.replace(/^\./, '')); }); }; Shell.prototype.out = function (s) { this.t.write(String(s).replace(/\n/g, '\r\n') + '\r\n'); }; Shell.prototype.err = function (s) { this.out(R + s + X); }; Shell.prototype.exec = function (raw) { var self = this, parts = raw.split(/&&|;/); for (var i = 0; i < parts.length; i++) { if (!self.run(parts[i].trim())) break; } if (this.o.onCommand) this.o.onCommand(raw, this); }; Shell.prototype.run = function (cmd) { if (!cmd) return true; var toks = [], m, re = /"([^"]*)"|'([^']*)'|(\S+)/g; while ((m = re.exec(cmd))) toks.push(m[1] != null ? m[1] : m[2] != null ? m[2] : m[3]); var redir = null, ri = toks.findIndex(function (t) { return t === '>' || t === '>>'; }); if (ri > -1) { redir = { mode: toks[ri], file: toks[ri + 1] }; toks = toks.slice(0, ri); } var c = toks[0], a = toks.slice(1), self = this, flags = a.filter(function (x) { return x[0] === '-'; }).join(''), args = a.filter(function (x) { return x[0] !== '-'; }); var capture = null, print = function (s) { if (redir) capture = (capture ? capture + '\n' : '') + s; else self.out(s); }; switch (c) { case 'help': print('Commandes : pwd, ls, cd, mkdir, touch, echo, cat, rm, rmdir, whoami, hostname, date, uname, ip a, clear'); break; case 'pwd': print(this.cwd); break; case 'whoami': print(this.user); break; case 'hostname': print(this.host); break; case 'date': print(new Date().toLocaleString('fr-FR', { dateStyle: 'full', timeStyle: 'medium' })); break; case 'uname': print(flags.indexOf('a') > -1 ? 'Linux ' + this.host + ' 6.12.0-amd64 #1 SMP Debian x86_64 GNU/Linux' : 'Linux'); break; case 'clear': this.t.clear(); break; case 'ip': if (/^a|^addr/.test(args[0] || '')) print('1: lo: mtu 65536\n inet 127.0.0.1/8 scope host lo\n2: ens3: mtu 1500\n inet 192.168.10.10/26 brd 192.168.10.63 scope global ens3'); else print('Usage : ip addr'); break; case 'ls': { var d = this.path(args[0] || '.'); if (!this.dirs.has(d)) { this.err("ls: impossible d'accéder à '" + (args[0] || '.') + "': Aucun fichier ou dossier de ce nom"); return false; } var all = flags.indexOf('a') > -1, long = flags.indexOf('l') > -1; var list = this.children(d).filter(function (e) { return all || e.n[0] !== '.'; }); if (all) list = [{ n: '.', dir: true }, { n: '..', dir: true }].concat(list); var col = function (e) { return redir ? e.n : e.dir ? B + e.n + X : e.n; }; if (long) { var now = new Date(), stamp = now.toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' }) + ' ' + now.toTimeString().slice(0, 5); print('total ' + list.length * 4); list.forEach(function (e) { print((e.dir ? 'drwxr-xr-x 2 ' : '-rw-r--r-- 1 ') + self.user + ' ' + self.user + ' ' + String(e.dir ? 4096 : e.size).padStart(5) + ' ' + stamp + ' ' + col(e)); }); } else if (list.length) print(list.map(col).join(' ')); break; } case 'cd': { var p = this.path(args[0]); if (!this.dirs.has(p)) { this.err('bash: cd: ' + args[0] + ': Aucun fichier ou dossier de ce nom'); return false; } this.cwd = p; break; } case 'mkdir': if (!args.length) { this.err('mkdir: opérande manquant'); return false; } for (var i = 0; i < args.length; i++) { var np = this.path(args[i]); if (this.dirs.has(np)) { if (flags.indexOf('p') < 0) { this.err("mkdir: impossible de créer le répertoire « " + args[i] + " »: Le fichier existe"); return false; } continue; } if (!this.dirs.has(this.parent(np))) { if (flags.indexOf('p') < 0) { this.err("mkdir: impossible de créer le répertoire « " + args[i] + " »: Aucun fichier ou dossier de ce nom"); return false; } var acc = ''; np.split('/').slice(1).forEach(function (s) { acc += '/' + s; self.dirs.add(acc); }); } else this.dirs.add(np); } break; case 'touch': if (!args.length) { this.err('touch: opérande de fichier manquant'); return false; } args.forEach(function (f) { var fp = self.path(f); if (!self.files.has(fp)) self.files.set(fp, ''); }); break; case 'echo': print(args.join(' ')); break; case 'cat': { var f = this.path(args[0]); if (this.dirs.has(f)) { this.err('cat: ' + args[0] + ': est un dossier'); return false; } if (!this.files.has(f)) { this.err('cat: ' + args[0] + ': Aucun fichier ou dossier de ce nom'); return false; } var v = this.files.get(f); if (v) print(v); break; } case 'rm': for (var k = 0; k < args.length; k++) { var rp = this.path(args[k]); if (this.dirs.has(rp)) { this.err("rm: impossible de supprimer '" + args[k] + "': est un dossier"); return false; } if (!this.files.delete(rp)) { this.err("rm: impossible de supprimer '" + args[k] + "': Aucun fichier ou dossier de ce nom"); return false; } } break; case 'rmdir': { var dp = this.path(args[0]); if (!this.dirs.has(dp)) { this.err("rmdir: '" + args[0] + "': Aucun fichier ou dossier de ce nom"); return false; } if (this.children(dp).length) { this.err("rmdir: '" + args[0] + "': Le dossier n'est pas vide"); return false; } this.dirs.delete(dp); break; } case 'sudo': if (this.o.commands) return this.run(a.join(' ')); print(Y + 'Pas besoin de sudo pour ce lab : tout se passe dans ton dossier.' + X); break; default: if (this.o.commands && this.o.commands[c]) { var res = this.o.commands[c](a, this); if (res === false) return false; if (res) print(res); break; } this.err('bash: ' + c + ' : commande introuvable'); return false; } if (redir) { if (!redir.file) { this.err('bash: erreur de syntaxe près du symbole inattendu « newline »'); return false; } var fp2 = this.path(redir.file); if (!this.dirs.has(this.parent(fp2))) { this.err('bash: ' + redir.file + ': Aucun fichier ou dossier de ce nom'); return false; } var prev = redir.mode === '>>' && this.files.has(fp2) ? this.files.get(fp2) : ''; this.files.set(fp2, prev ? prev + '\n' + (capture || '') : (capture || '')); } return true; }; Shell.prototype.key = function (d) { if (this.busy) return; var t = this.t; if (d === '\r') { t.write('\r\n'); var l = this.line; this.line = ''; if (l.trim()) { this.hist.push(l); } this.hi = this.hist.length; this.exec(l); this.prompt(); return; } if (d === '\x7f') { if (this.line) { this.line = this.line.slice(0, -1); t.write('\b \b'); } return; } if (d === '\x03') { t.write('^C\r\n'); this.line = ''; this.prompt(); return; } if (d === '\x0c') { t.clear(); return; } if (d === '\x1b[A' || d === '\x1b[B') { if (!this.hist.length) return; this.hi = Math.max(0, Math.min(this.hist.length, this.hi + (d === '\x1b[A' ? -1 : 1))); var h = this.hist[this.hi] || ''; t.write('\b \b'.repeat(this.line.length)); this.line = h; t.write(h); return; } if (d[0] === '\x1b') return; var clean = d.replace(/[\x00-\x1f]/g, ''); this.line += clean; t.write(clean); }; Shell.prototype.demo = function (cmds, loop) { var self = this, reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; this.busy = true; var i = 0; function next() { if (i >= cmds.length) { if (!loop || reduce) return; return setTimeout(function () { self.t.clear(); self.t.write('\x1b[2J\x1b[H'); self.cwd = self.home; self.dirs = new Set(['/', '/home', self.home, '/etc', '/tmp', '/var', '/var/log']); self.files.forEach(function (v, k) { if (k.indexOf(self.home + '/') === 0 && k !== self.home + '/.bashrc') self.files.delete(k); }); i = 0; self.prompt(); setTimeout(next, 600); }, 3500); } var c = cmds[i++], j = 0; if (reduce) { self.t.write(c + '\r\n'); self.exec(c); self.prompt(); return next(); } (function type() { if (j < c.length) { self.t.write(c[j++]); return setTimeout(type, 40 + Math.random() * 50); } setTimeout(function () { self.t.write('\r\n'); self.exec(c); self.prompt(); setTimeout(next, 900); }, 300); })(); } setTimeout(next, 700); }; function mount(el, o) { o = o || {}; var term = new window.Terminal({ theme: THEME, fontFamily: '"IBM Plex Mono", ui-monospace, monospace', fontSize: o.fontSize || 14, lineHeight: 1.25, cursorBlink: true, cursorStyle: 'block', scrollback: 500, convertEol: false, disableStdin: !!o.demo, allowTransparency: false }); var fit = window.FitAddon ? new window.FitAddon.FitAddon() : null; if (fit) term.loadAddon(fit); term.open(el); var fitNow = function () { try { fit && fit.fit(); } catch (e) {} }; var raf, doFit = function () { cancelAnimationFrame(raf); raf = requestAnimationFrame(fitNow); }; fitNow(); window.addEventListener('resize', doFit); if (window.ResizeObserver) new ResizeObserver(doFit).observe(el); var sh = new Shell(term, o); if (o.welcome) term.write(o.welcome.replace(/\n/g, '\r\n') + '\r\n'); sh.prompt(); if (o.demo) sh.demo(o.demo, o.loop !== false); else { term.onData(function (d) { sh.key(d); }); if (o.focus) term.focus(); } return sh; } window.SyllabusTerminal = { mount: function (el, o) { var go = function () { mount(el, o); }; (document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve()).then(go); } }; })();