Files
website-cv/js/terminal.js
T
2026-08-09 23:42:23 +02:00

735 lines
27 KiB
JavaScript

// ── Fake terminal: click the Environment card's icon ──────────
(function () {
const glyph = document.querySelector('.skill-card-icon .fa-terminal');
if (!glyph) return;
const icon = glyph.closest('.skill-card-icon');
const HOST = 'gade.gg';
const KERNEL = '6.6-goat';
const T0 = performance.now();
// Current user: su can change it (root's password leaked in auth.log),
// exit pops back. Root passes every permission check and may write.
const USERS = {
root: ['root'],
nikolaj: ['home', 'nikolaj'],
guest: ['home', 'guest'],
pepsi: ['home', 'pepsi'],
};
const PASSWORDS = { root: 'Pepsi2022' };
let user = 'guest';
let home = USERS.guest;
const userStack = [];
let pendingSu = null; // user being su'd to while the password prompt is up
// Filesystem: dirs are { children }, files are strings — or functions
// returning a string, for live content (.bash_history). locked dirs
// deny everyone but root and their owner. Only root may write.
const dir = (children) => ({ children });
const locked = (owner, children) => ({ children, locked: owner });
const DENIED = { denied: true }; // a path tried to pass through a locked dir
const isFile = (n) => typeof n === 'string' || typeof n === 'function';
const fileBody = (n) => (typeof n === 'function' ? n() : n);
const allowed = (n) => !n.locked || user === 'root' || user === n.locked;
const GOAT = ` %%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%
%%%% %%%%%%%%%%%
%%% %%%%%%%%%%%%%%%
%% %%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%%% %%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%
%%%%%%%%%%%%%%%
`;
const BIN = '\u007fELF';
const FS = dir({
bin: dir({ cat: BIN, echo: BIN, gsh: BIN, ls: BIN, nano: BIN, rm: BIN, sh: BIN }),
etc: dir({
hostname: 'gade.gg\n',
hosts:
'127.0.0.1 localhost\n' +
'10.0.0.1 bulbasaur\n' +
'10.0.0.6 charizard\n' +
'10.0.0.29 nidoran-f\n' +
'10.0.0.32 nidoran-m\n' +
'10.0.0.58 growlithe\n' +
'10.0.0.63 abra\n' +
'10.0.0.64 kadabra\n' +
'10.0.0.133 eevee\n' +
'10.0.0.150 mewtwo\n' +
'10.0.0.151 mew\n',
motd: "Welcome to gade.gg.\nType 'help' for commands.\n",
passwd:
'root:x:0:0:root:/root:/bin/sh\n' +
'nikolaj:x:1000:1000:Nikolaj Gade:/home/nikolaj:/bin/fish\n' +
'guest:x:1001:1001:guest:/home/guest:/bin/gsh\n' +
'pepsi:x:1002:1002:Pepsi Max Lime Gade:/home/pepsi:/usr/bin/meow\n',
}),
home: dir({
guest: dir({
'readme.txt': 'Nothing here yet. Have a look around.\n',
'.bash_history': () => (history.length ? history.join('\n') + '\n' : ''),
'.bashrc': "# ~/.bashrc\nPS1='\\u@\\h:\\w\\$ '\nalias sl=ls\n",
}),
nikolaj: locked('nikolaj', {
'array-props.pdf': '%PDF-1.7\n%\n',
}),
pepsi: dir({
'.plan':
'nap.\n' +
'nap.\n' +
'knock something off the table.\n' +
'nap.\n',
}),
}),
proc: dir({
uptime: () => {
const s = (performance.now() - T0) / 1000;
return s.toFixed(2) + ' ' + (s * 4).toFixed(2) + '\n';
},
version:
'Linux version ' + KERNEL + ' (rats@gade.gg) (gcc (GCC) 14.2.1) #1 SMP PREEMPT\n',
}),
root: locked('root', {
goat: GOAT,
'.bash_history': 'rm -rf /\n',
}),
usr: dir({
bin: dir({ meow: BIN }),
share: dir({
centvrion: dir({ 'salve.cent': '// Hello, world\nDIC("SALVE MVNDE")\n' }),
}),
}),
var: dir({
log: dir({
'auth.log':
'Aug 9 03:12:44 gade sshd[812]: Failed password for root from 10.0.0.150 port 51434 ssh2\n' +
'Aug 9 03:12:51 gade sshd[815]: Failed password for invalid user Pepsi2022 from 10.0.0.150 port 51438 ssh2\n' +
'Aug 9 03:13:02 gade sshd[819]: Accepted password for root from 10.0.0.150 port 51442 ssh2\n',
}),
}),
});
let cwd = [...USERS.guest];
function resolvePath(path) {
if (path === '~') path = '/' + home.join('/');
else if (path.startsWith('~/')) path = '/' + home.join('/') + path.slice(1);
const segs = path.startsWith('/') ? [] : [...cwd];
for (const part of path.split('/')) {
if (part === '' || part === '.') continue;
if (part === '..') segs.pop();
else segs.push(part);
}
return segs;
}
function nodeAt(segs) {
let node = FS;
for (const s of segs) {
if (!allowed(node)) return DENIED;
if (!node.children || !Object.prototype.hasOwnProperty.call(node.children, s)) return null;
node = node.children[s];
}
return node;
}
function displayPath(segs) {
const p = '/' + segs.join('/');
const h = '/' + home.join('/');
if (p === h) return '~';
if (p.startsWith(h + '/')) return '~' + p.slice(h.length);
return p;
}
const promptText = () =>
user + '@' + HOST + ':' + displayPath(cwd) + (user === 'root' ? '#' : '$');
let dialog = null;
let screen = null;
let out = null;
let input = null;
let promptEl = null;
let termTitle = null;
function becomeUser(u) {
user = u;
home = USERS[u];
// The quiet flag a future achievements system reads
if (u === 'root') {
try { localStorage.setItem('termRoot', '1'); } catch (e) {}
if (window.achv) window.achv.unlock('hacker');
}
promptEl.textContent = promptText();
termTitle.textContent = u + '@' + HOST;
}
// Real history: ↑ recalls it, ~/.bash_history reads it, localStorage
// keeps it. Seeded so a first visit finds a previous guest's session.
const HIST_SEED = ['ls', 'cd /', 'ls', 'cat etc/motd', 'cd'];
const HIST_MAX = 200;
let history = HIST_SEED;
try { history = JSON.parse(localStorage.getItem('termHistory')) || HIST_SEED; } catch (e) {}
if (!Array.isArray(history)) history = HIST_SEED;
history = history.slice(-HIST_MAX).map(String);
let hIdx = history.length;
let draft = '';
function saveHistory() {
try { localStorage.setItem('termHistory', JSON.stringify(history)); } catch (e) {}
}
// parts: [{ text, cls? }] — one scrollback line
function line(parts) {
const el = document.createElement('div');
el.className = 'term-line';
for (const part of parts) {
if (part.cls) {
const span = document.createElement('span');
span.className = part.cls;
span.textContent = part.text;
el.appendChild(span);
} else {
el.appendChild(document.createTextNode(part.text));
}
}
out.appendChild(el);
}
const print = (text) => line([{ text }]);
// hidden commands work but stay out of help and tab completion —
// finding them is the point
const ALIASES = { sl: 'ls' }; // honoring ~/.bashrc
const COMMANDS = {
cat: { usage: 'cat <file>', desc: 'print a file', run(args) {
for (const t of args) {
const node = nodeAt(resolvePath(t));
if (node === null) print('cat: ' + t + ': No such file or directory');
else if (node === DENIED) print('cat: ' + t + ': Permission denied');
else if (isFile(node)) {
const body = fileBody(node).replace(/\n$/, '');
if (body) print(body);
} else print('cat: ' + t + ': Is a directory');
}
} },
cd: { usage: 'cd [dir]', desc: 'change directory', run(args) {
const target = args[0] || '~';
const segs = resolvePath(target);
const node = nodeAt(segs);
if (node === null) print('cd: no such file or directory: ' + target);
else if (node === DENIED || !allowed(node)) print('cd: permission denied: ' + target);
else if (isFile(node)) print('cd: not a directory: ' + target);
else {
cwd = segs;
promptEl.textContent = promptText();
}
} },
clear: { usage: 'clear', desc: 'clear the screen', run() { out.textContent = ''; } },
echo: { usage: 'echo [text]', desc: 'print text', run(args) { print(args.join(' ')); } },
exit: { usage: 'exit', desc: 'close the terminal', run() {
if (userStack.length) becomeUser(userStack.pop());
else dialog.close();
} },
help: { usage: 'help', desc: 'list commands', run() {
const names = Object.keys(COMMANDS).filter((n) => !COMMANDS[n].hidden).sort();
const pad = Math.max(...names.map((n) => COMMANDS[n].usage.length)) + 2;
names.forEach((n) => print(' ' + COMMANDS[n].usage.padEnd(pad) + COMMANDS[n].desc));
} },
history: { hidden: true, usage: 'history', desc: '', run() {
history.forEach((h, i) => print(String(i + 1).padStart(5) + ' ' + h));
} },
ls: { usage: 'ls [-a] [dir]', desc: 'list directory contents', run(args) {
const all = args.some((a) => /^-[a-zA-Z]*a/.test(a));
const paths = args.filter((a) => !a.startsWith('-'));
const target = paths[0] || '.';
const node = nodeAt(resolvePath(target));
if (node === null) { print("ls: cannot access '" + target + "': No such file or directory"); return; }
if (node === DENIED || !allowed(node)) { print("ls: cannot open directory '" + target + "': Permission denied"); return; }
if (isFile(node)) { print(target); return; }
let names = Object.keys(node.children).sort();
names = all ? ['.', '..', ...names] : names.filter((n) => !n.startsWith('.'));
if (!names.length) return;
const parts = [];
names.forEach((n, i) => {
if (i) parts.push({ text: ' ' });
const child = n === '.' || n === '..' ? FS : node.children[n];
parts.push(isFile(child) ? { text: n } : { text: n, cls: 'term-dir' });
});
line(parts);
} },
meow: { hidden: true, usage: 'meow', desc: '', run() { print('meow'); } },
nano: { usage: 'nano [file]', desc: 'edit a file', run(args) {
const target = args[0];
let name = 'New Buffer';
let content = '';
let segs = null;
if (target) {
segs = resolvePath(target);
const node = nodeAt(segs);
if (node === DENIED) { print('nano: ' + target + ': Permission denied'); return; }
if (node !== null && !isFile(node)) { print('nano: ' + target + ': Is a directory'); return; }
if (node !== null) content = fileBody(node);
name = segs[segs.length - 1] || target;
}
openNano(name, content, segs);
} },
pwd: { usage: 'pwd', desc: 'print working directory', run() { print('/' + cwd.join('/')); } },
rm: { usage: 'rm [-rf] <file>', desc: 'remove files', run(args) {
const recursive = args.some((a) => /^-[a-zA-Z]*r/i.test(a) || a === '--recursive');
const targets = args.filter((a) => !a.startsWith('-'));
if (!targets.length) { print('rm: missing operand'); return; }
for (const t of targets) {
const segs = resolvePath(t);
// The one write the filesystem allows: rm -rf / is the real
// delete-website flow, confirmation dialog and all
if (recursive && segs.length === 0) {
const dlg = document.getElementById('deleteDialog');
if (dlg) {
dlg.showModal();
// Chromium: once a nested modal has closed, the input under it
// still gets key events but no longer inserts text — only a
// fresh input element types again
dlg.addEventListener('close', () => {
const fresh = input.cloneNode();
input.replaceWith(fresh);
input = fresh;
wireInput(fresh);
fresh.focus();
}, { once: true });
return;
}
}
const node = nodeAt(segs);
if (node === null) print("rm: cannot remove '" + t + "': No such file or directory");
else if (node === DENIED) print("rm: cannot remove '" + t + "': Permission denied");
else if (!isFile(node) && !recursive) print("rm: cannot remove '" + t + "': Is a directory");
else if (user !== 'root') print("rm: cannot remove '" + t + "': Permission denied");
else {
const parent = nodeAt(segs.slice(0, -1));
delete parent.children[segs[segs.length - 1]];
}
}
} },
roll: { usage: 'roll [NdS+M]', desc: 'roll dice', run(args) {
const spec = (args[0] || 'd20').toLowerCase();
const m = /^(\d*)d(\d+)([+-]\d+)?$/.exec(spec);
if (!m) { print('roll: usage: roll [NdS+M], e.g. roll 2d6+1'); return; }
const n = Math.min(parseInt(m[1] || '1', 10) || 1, 100);
const sides = parseInt(m[2], 10);
const mod = parseInt(m[3] || '0', 10);
const rolls = [];
for (let i = 0; i < n; i++) rolls.push(1 + Math.floor(Math.random() * sides));
const total = rolls.reduce((a, r) => a + r, 0) + mod;
if (n === 1 && !mod) { print(spec + ': ' + total); return; }
let breakdown = rolls.join(' + ');
if (mod) breakdown += mod > 0 ? ' + ' + mod : ' - ' + -mod;
print(spec + ': ' + breakdown + ' = ' + total);
} },
su: { hidden: true, usage: 'su [user]', desc: '', run(args) {
const target = args.filter((a) => !a.startsWith('-'))[0] || 'root';
if (!(target in USERS)) { print('su: user ' + target + ' does not exist'); return; }
if (user === 'root') {
userStack.push(user);
becomeUser(target);
return;
}
pendingSu = target;
promptEl.textContent = 'Password:';
input.type = 'password';
} },
sudo: { hidden: true, usage: 'sudo', desc: '', run(args) {
if (user !== 'root') {
print(user + ' is not in the sudoers file. This incident will be reported.');
return;
}
if (!args.length) return;
const cmd = COMMANDS[ALIASES[args[0]] || args[0]];
if (cmd) cmd.run(args.slice(1));
else print('sudo: ' + args[0] + ': command not found');
} },
uname: { hidden: true, usage: 'uname', desc: '', run(args) {
if (args.includes('-a')) print('Linux ' + HOST + ' ' + KERNEL + ' #1 SMP PREEMPT x86_64 GNU/Linux');
else if (args.includes('-r')) print(KERNEL);
else print('Linux');
} },
whoami: { usage: 'whoami', desc: 'print user name', run() { print(user); } },
};
function runLine(raw) {
line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: raw }]);
const trimmed = raw.trim();
if (trimmed) {
if (trimmed !== history[history.length - 1]) {
history.push(trimmed);
if (history.length > HIST_MAX) history.shift();
saveHistory();
}
const tokens = trimmed.split(/\s+/);
const cmd = COMMANDS[ALIASES[tokens[0]] || tokens[0]];
if (cmd) cmd.run(tokens.slice(1));
else print('gsh: ' + tokens[0] + ': command not found');
}
hIdx = history.length;
draft = '';
screen.scrollTop = screen.scrollHeight;
}
// Tab: complete command names (first word) or paths (arguments) to the
// longest shared prefix; a stuck tab with several matches lists them
function complete() {
const v = input.value;
const token = /(\S*)$/.exec(v)[1];
const before = v.slice(0, v.length - token.length);
let base = '';
let candidates;
if (before.trim() === '') {
candidates = Object.keys(COMMANDS)
.filter((c) => !COMMANDS[c].hidden && c.startsWith(token))
.map((c) => c + ' ');
} else {
const slash = token.lastIndexOf('/');
base = slash >= 0 ? token.slice(0, slash + 1) : '';
const prefix = token.slice(slash + 1);
const node = nodeAt(resolvePath(base || '.'));
if (!node || node === DENIED || !allowed(node) || isFile(node)) return;
candidates = Object.keys(node.children)
.filter((n) => n.startsWith(prefix) && (prefix.startsWith('.') || !n.startsWith('.')))
.map((n) => n + (isFile(node.children[n]) ? ' ' : '/'));
}
if (!candidates.length) return;
let lcp = candidates[0];
for (const c of candidates) {
while (!c.startsWith(lcp)) lcp = lcp.slice(0, -1);
}
if (candidates.length === 1) {
input.value = before + base + candidates[0];
} else if (lcp && base + lcp !== token) {
input.value = before + base + lcp;
} else {
line(candidates.map((c, i) => ({ text: (i ? ' ' : '') + c.trimEnd() })));
screen.scrollTop = screen.scrollHeight;
}
}
// ── nano: the one installed editor. Editing works; saving is another
// matter (the filesystem is read-only, and it says so like nano would)
let nano = null;
let nanoTitleName = null;
let nanoTitleMod = null;
let nanoEdit = null;
let nanoStatus = null;
let nanoKeys = null;
let nanoName = '';
let nanoSegs = null;
let nanoOriginal = '';
let nanoPrompting = false;
const NANO_EDIT_KEYS = [['^X', 'Exit'], ['^O', 'Write Out']];
function setNanoKeys(pairs) {
nanoKeys.textContent = '';
pairs.forEach(([key, label]) => {
const k = document.createElement('span');
k.className = 'nano-key';
k.textContent = key;
nanoKeys.appendChild(k);
nanoKeys.appendChild(document.createTextNode(' ' + label + ' '));
});
}
function setNanoStatus(msg) {
nanoStatus.textContent = '';
if (msg) {
const s = document.createElement('span');
s.textContent = msg;
nanoStatus.appendChild(s);
}
}
function updateNanoTitle() {
nanoTitleName.textContent = nanoName;
nanoTitleMod.textContent = nanoEdit.value === nanoOriginal ? '' : 'Modified';
}
function buildNano() {
nano = document.createElement('div');
nano.className = 'nano';
const title = document.createElement('div');
title.className = 'nano-bar nano-title';
const version = document.createElement('span');
version.textContent = 'GNU nano 8.1';
nanoTitleName = document.createElement('span');
nanoTitleMod = document.createElement('span');
title.append(version, nanoTitleName, nanoTitleMod);
nanoEdit = document.createElement('textarea');
nanoEdit.className = 'nano-edit';
nanoEdit.spellcheck = false;
nanoEdit.setAttribute('aria-label', 'Editor');
nanoStatus = document.createElement('div');
nanoStatus.className = 'nano-status';
nanoKeys = document.createElement('div');
nanoKeys.className = 'nano-keys';
nano.append(title, nanoEdit, nanoStatus, nanoKeys);
nanoEdit.addEventListener('keydown', nanoKeydown);
nanoEdit.addEventListener('input', updateNanoTitle);
}
function openNano(name, content, segs) {
if (!nano) buildNano();
nanoName = name;
nanoSegs = segs;
nanoOriginal = content;
nanoEdit.value = content;
nanoPrompting = false;
setNanoStatus('');
setNanoKeys(NANO_EDIT_KEYS);
updateNanoTitle();
screen.classList.add('nano-open');
screen.appendChild(nano);
nanoEdit.focus();
nanoEdit.setSelectionRange(0, 0);
}
function closeNano() {
nanoPrompting = false;
screen.classList.remove('nano-open');
nano.remove();
input.focus();
screen.scrollTop = screen.scrollHeight;
}
function nanoAskExit() {
nanoPrompting = true;
setNanoStatus('Save modified buffer?');
setNanoKeys([['Y', 'Yes'], ['N', 'No'], ['^C', 'Cancel']]);
}
function nanoEndPrompt(msg) {
nanoPrompting = false;
setNanoStatus(msg);
setNanoKeys(NANO_EDIT_KEYS);
}
// Writing works for root and no one else; sets its own status line
function nanoSave() {
if (user !== 'root') {
setNanoStatus('[ Error writing ' + nanoName + ': Permission denied ]');
return false;
}
if (!nanoSegs || !nanoSegs.length) {
setNanoStatus('[ Cancelled ]');
return false;
}
const parent = nodeAt(nanoSegs.slice(0, -1));
if (!parent || parent === DENIED || isFile(parent)) {
setNanoStatus('[ Directory does not exist ]');
return false;
}
parent.children[nanoSegs[nanoSegs.length - 1]] = nanoEdit.value;
nanoOriginal = nanoEdit.value;
updateNanoTitle();
const n = nanoEdit.value ? nanoEdit.value.replace(/\n$/, '').split('\n').length : 0;
setNanoStatus('[ Wrote ' + n + ' line' + (n === 1 ? '' : 's') + ' ]');
return true;
}
function nanoKeydown(e) {
if (nanoPrompting) {
e.preventDefault();
const k = e.key.toLowerCase();
if (k === 'y') {
nanoPrompting = false;
setNanoKeys(NANO_EDIT_KEYS);
if (nanoSave()) closeNano();
} else if (k === 'n') closeNano();
else if ((k === 'c' && e.ctrlKey) || e.key === 'Escape') nanoEndPrompt('[ Cancelled ]');
return;
}
if (e.ctrlKey && !e.altKey && !e.metaKey) {
const k = e.key.toLowerCase();
if (k === 'x') {
e.preventDefault();
if (nanoEdit.value !== nanoOriginal) nanoAskExit();
else closeNano();
} else if (k === 'o' || k === 's') {
e.preventDefault();
nanoSave();
}
return; // other ctrl combos (copy, paste, undo) keep their defaults
}
if (e.key === 'Escape') {
e.preventDefault();
if (nanoEdit.value !== nanoOriginal) nanoAskExit();
else closeNano();
} else if (e.key === 'Tab') {
e.preventDefault();
nanoEdit.setRangeText('\t', nanoEdit.selectionStart, nanoEdit.selectionEnd, 'end');
updateNanoTitle();
}
}
function build() {
dialog = document.createElement('dialog');
dialog.className = 'term-dialog';
dialog.setAttribute('aria-label', 'Terminal');
const bar = document.createElement('div');
bar.className = 'term-titlebar';
termTitle = document.createElement('span');
termTitle.className = 'term-title';
termTitle.textContent = user + '@' + HOST;
bar.appendChild(termTitle);
screen = document.createElement('div');
screen.className = 'term-screen';
out = document.createElement('div');
out.className = 'term-out';
out.setAttribute('role', 'log');
const row = document.createElement('div');
row.className = 'term-inputrow';
promptEl = document.createElement('span');
promptEl.className = 'term-prompt';
promptEl.setAttribute('aria-hidden', 'true');
promptEl.textContent = promptText();
input = document.createElement('input');
input.className = 'term-input';
input.type = 'text';
input.autocapitalize = 'off';
input.autocomplete = 'off';
input.spellcheck = false;
input.setAttribute('aria-label', 'Command input');
row.append(promptEl, input);
screen.append(out, row);
dialog.append(bar, screen);
document.body.appendChild(dialog);
const motd = nodeAt(resolvePath('/etc/motd'));
if (typeof motd === 'string') print(motd.replace(/\n$/, ''));
wireInput(input);
// Click focuses the input, but not when selecting scrollback text
screen.addEventListener('click', () => {
const sel = window.getSelection();
if (!sel || sel.isCollapsed) input.focus();
});
// Click on the backdrop closes, like the delete dialog
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
// While nano is up, Escape belongs to nano, not the dialog
dialog.addEventListener('cancel', (e) => {
if (screen.classList.contains('nano-open')) e.preventDefault();
});
}
function wireInput(el) {
el.addEventListener('keydown', (e) => {
if (e.ctrlKey && !e.altKey && !e.metaKey) {
const k = e.key.toLowerCase();
if (k === 'c') {
e.preventDefault();
if (pendingSu) {
pendingSu = null;
input.type = 'text';
line([{ text: 'Password:^C' }]);
promptEl.textContent = promptText();
} else {
line([{ text: promptText() + ' ', cls: 'term-ps1' }, { text: input.value + '^C' }]);
}
input.value = '';
hIdx = history.length;
draft = '';
screen.scrollTop = screen.scrollHeight;
} else if (k === 'd') {
e.preventDefault();
if (pendingSu) {
pendingSu = null;
input.type = 'text';
input.value = '';
line([{ text: 'Password:' }]);
promptEl.textContent = promptText();
} else if (input.value === '') COMMANDS.exit.run();
} else if (k === 'l') {
e.preventDefault();
out.textContent = '';
}
return; // unhandled ctrl combos (paste, …) keep their defaults
}
if (e.key === 'Enter') {
// preventDefault kills the trailing keypress: if a command opens a
// modal (rm -rf /), the keypress would land on its autofocused
// button and click it before the dialog is ever seen
e.preventDefault();
const raw = input.value;
input.value = '';
if (pendingSu) {
// password attempts stay out of scrollback and history
const target = pendingSu;
pendingSu = null;
input.type = 'text';
line([{ text: 'Password:' }]);
if (raw === PASSWORDS[target]) {
userStack.push(user);
becomeUser(target);
} else {
print('su: Authentication failure');
promptEl.textContent = promptText();
}
screen.scrollTop = screen.scrollHeight;
return;
}
runLine(raw);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (pendingSu) return;
if (hIdx > 0) {
if (hIdx === history.length) draft = input.value;
hIdx--;
input.value = history[hIdx];
input.setSelectionRange(input.value.length, input.value.length);
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (pendingSu) return;
if (hIdx < history.length) {
hIdx++;
input.value = hIdx === history.length ? draft : history[hIdx];
input.setSelectionRange(input.value.length, input.value.length);
}
} else if (e.key === 'Tab') {
e.preventDefault();
if (!pendingSu) complete();
}
});
}
function open() {
if (!dialog) build(); // lazy: no trace in the DOM until first opened
if (dialog.open) return;
dialog.showModal();
if (screen.classList.contains('nano-open')) nanoEdit.focus();
else input.focus();
screen.scrollTop = screen.scrollHeight;
}
icon.addEventListener('click', open);
})();