Compare commits

..

1 Commits

Author SHA1 Message Date
branch-loop 4f93196eeb we love to sniff farts!!!!! 2026-07-27 03:18:59 +01:00
28 changed files with 0 additions and 436 deletions
-5
View File
@@ -1,5 +0,0 @@
node_modules/
.env
data/config.json
data/host.key
*.log
-37
View File
@@ -1,37 +0,0 @@
# SSH Portfolio
A public, anonymous Node.js SSH server that turns a terminal into a customizable portfolio. Visitors connect with:
```sh
ssh -p 2222 localhost
```
## Quick start
```sh
npm install
npm start
```
The first run creates `data/config.json` from [`config.example.json`](config.example.json). Edit persona, pages, colors, ports, and admin keys, then use `reload` as an admin or restart the server. Pages are content-only; actions are registered in `src/commands.js`, so adding a command does not require changing the SSH transport.
## Authentication model
Anonymous `none` authentication is intentionally accepted for visitors. Add one or more base64 public-key blobs to `adminPublicKeys` to enable administrator mode; key authentication is optional and never required. Admins get `reload`, `theme`, and `announce` commands.
Because this is an intentionally open service, run it in a container or restricted account, use a dedicated port, and do not expose private data through configured pages.
## Owner notifications
Run `npm run notify` on the owner machine (or use `NOTIFICATION_PORT`) to receive chat and announcement notifications over a localhost-only socket. The protocol is newline-delimited JSON, so it is straightforward to replace with a desktop/mobile notification client. For a separate owner machine, forward the port with `ssh -N -L 7777:127.0.0.1:7777 <server>`.
## Extending
Pages and commands are source modules:
- `src/pages/home.js`, `about.js`, `projects.js`, and `links.js` are the premade editable pages.
- `src/pages/index.js` is the page registry. Add an import and registry entry when creating a new page; its filename becomes the SSH command.
- `src/commands/` contains the premade command modules (`chat`, `contact`, `announce`, and so on).
- `src/commands.js` is the command registry. Add an import there to expose a new command.
Each page exports `name`, `title`, and `render({ config, ctx, api })`. The renderer returns terminal text, so you can build extensive custom pages while inheriting the shared header, theme, and prompt. Pong is intentionally left as the next module: it can use the same session registry and owner-online check.
-11
View File
@@ -1,11 +0,0 @@
{
"persona": { "name": "Your Name", "tagline": "A short line about you", "location": "Earth", "email": "you@example.com" },
"theme": { "primary": "cyan", "accent": "yellow", "muted": "brightBlack" },
"pages": {
"about": { "title": "About", "lines": ["Tell visitors who you are.", "This file is deliberately easy to edit."] },
"projects": { "title": "Projects", "lines": ["Project One — what it does", "Project Two — what you learned"] },
"links": { "title": "Links", "lines": ["GitHub: https://github.com/yourname", "Website: https://example.com"] }
},
"adminPublicKeys": [],
"host": { "port": 2222, "hostKeyPath": "data/host.key", "notificationPort": 7777 }
}
BIN
View File
Binary file not shown.
-99
View File
@@ -1,99 +0,0 @@
{
"name": "ssh-portfolio",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ssh-portfolio",
"version": "0.1.0",
"dependencies": {
"ssh2": "^1.16.0"
},
"bin": {
"ssh-portfolio": "src/server.js",
"ssh-portfolio-notify": "src/notify-client.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
}
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"name": "ssh-portfolio",
"version": "0.1.0",
"description": "A customizable, public SSH portfolio for your online persona",
"type": "module",
"main": "src/server.js",
"bin": { "ssh-portfolio": "src/server.js", "ssh-portfolio-notify": "src/notify-client.js" },
"scripts": { "start": "node src/server.js", "notify": "node src/notify-client.js", "check": "node --check src/server.js && node --check src/notify-client.js" },
"engines": { "node": ">=20" },
"dependencies": { "ssh2": "^1.16.0" }
}
-36
View File
@@ -1,36 +0,0 @@
import * as home from './commands/home.js';
import * as help from './commands/help.js';
import * as pages from './commands/pages.js';
import * as open from './commands/open.js';
import * as contact from './commands/contact.js';
import * as chat from './commands/chat.js';
import * as clearCommand from './commands/clear.js';
import * as quit from './commands/quit.js';
import * as reload from './commands/reload.js';
import * as theme from './commands/theme.js';
import * as announce from './commands/announce.js';
import { pageMap } from './pages/index.js';
// Add a command module import here to make a new command available.
const commands = [home, help, pages, open, contact, chat, clearCommand, quit, reload, theme, announce];
export function createCommandRouter(api) {
const commandMap = new Map(commands.map(command => [command.name, command]));
const runPage = (ctx, name) => {
const page = pageMap.get(name);
return page ? page.render({ ...api, api, ctx }) : `No page named “${name}”. Try: pages`;
};
return {
home: ctx => runPage(ctx, 'home'),
execute(ctx, input) {
const [name, ...rest] = input.trim().split(/\s+/); const arg = rest.join(' ');
const command = commandMap.get(name || 'home') || pageMap.get(name);
if (!command) return `Unknown command: ${name}. Try “help”.`;
if (command.admin && !api.isAdmin(ctx)) return `Unknown command: ${name}. Try “help”.`;
if (pageMap.has(name) && !command.run) return runPage(ctx, name);
const result = command.run({ ...api, api, ctx, arg, config: api.config, pageMap, visibleCommands: commands });
if (typeof result === 'string' && result.startsWith('__PAGE__:')) return runPage(ctx, result.slice(9));
return result;
}
};
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'announce'; export const description = 'Broadcast an announcement'; export const admin = true;
export function run({ arg, api }) { if (!arg) return 'Usage: announce <message>'; api.broadcast(`OWNER: ${arg}`); api.sendNotification(`OWNER: ${arg}`); return 'Announcement sent.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'chat'; export const description = 'Chat with the owner/visitors';
export function run({ arg, ctx, api }) { if (!arg) return 'Usage: chat <message>'; const author = api.isAdmin(ctx) ? `${api.config.persona?.name || 'Owner'} [owner]` : (ctx.username || 'visitor'); const line = `${author}: ${arg}`; api.broadcast(line); api.sendNotification(line); return api.ownerOnline() ? 'Message sent to the owner.' : 'Message posted; the owner is currently offline.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'clear'; export const description = 'Clear the screen';
export function run() { return ''; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'contact'; export const description = 'Show contact details';
export function run({ config }) { return `Email: ${config.persona?.email || 'not configured'}`; }
-6
View File
@@ -1,6 +0,0 @@
export const name = 'help'; export const description = 'Show this help';
export function run({ visibleCommands, pageMap, isAdmin, ctx }) {
const actions = visibleCommands.filter(command => !command.admin || isAdmin(ctx)).map(command => `${command.name.padEnd(18)} ${command.description}`);
const pages = [...pageMap.values()].filter(page => !visibleCommands.some(command => command.name === page.name)).map(page => `${page.name.padEnd(18)} Open ${page.title || page.name}`);
return [...actions, ...pages].join('\n');
}
-2
View File
@@ -1,2 +0,0 @@
export const name = 'home'; export const description = 'Return home';
export function run({ pageMap, config, ownerOnline, ctx, api }) { return pageMap.get('home').render({ config, ownerOnline, ctx, api }); }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'open'; export const description = 'Open a page';
export function run({ arg }) { return arg ? `__PAGE__:${arg}` : 'Usage: open <page>'; }
-3
View File
@@ -1,3 +0,0 @@
import { color } from '../terminal.js';
export const name = 'pages'; export const description = 'List available pages';
export function run({ pageMap, config }) { return [...pageMap.values()].map(page => `${color(config.theme?.accent || 'yellow', page.name.padEnd(14))} ${page.title || page.name}`).join('\n') || 'No pages configured.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'quit'; export const description = 'Disconnect';
export function run({ ctx }) { ctx.channel?.end(); return ''; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'reload'; export const description = 'Reload configuration'; export const admin = true;
export function run({ api }) { api.reload(); return 'Configuration reloaded.'; }
-2
View File
@@ -1,2 +0,0 @@
export const name = 'theme'; export const description = 'Set runtime primary color'; export const admin = true;
export function run({ arg, config }) { if (!arg) return 'Usage: theme <color>'; config.theme.primary = arg; return `Primary color set to ${arg} (runtime only).`; }
-19
View File
@@ -1,19 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const file = process.env.PORTFOLIO_CONFIG || path.join(root, 'data', 'config.json');
const example = path.join(root, 'config.example.json');
export function loadConfig() {
if (!fs.existsSync(file)) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.copyFileSync(example, file);
console.log(`Created ${file}; edit it to personalize your portfolio.`);
}
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
config.host ??= {};
config.host.port = Number(process.env.PORT || config.host.port || 2222);
config.host.notificationPort = Number(process.env.NOTIFICATION_PORT || config.host.notificationPort || 7777);
return config;
}
export const configPath = file;
-6
View File
@@ -1,6 +0,0 @@
#!/usr/bin/env node
import net from 'node:net';
const port = Number(process.env.NOTIFICATION_PORT || 7777);
const socket = net.createConnection({ host: '127.0.0.1', port }, () => console.log(`Listening for SSH portfolio notifications on localhost:${port}`));
socket.on('data', data => data.toString().split('\n').filter(Boolean).forEach(line => { try { const item = JSON.parse(line); if (item.message) console.log(`\n[ssh-portfolio] ${item.message}`); } catch {} }));
socket.on('error', err => { console.error(`Unable to connect to notification server: ${err.message}`); process.exitCode = 1; });
-10
View File
@@ -1,10 +0,0 @@
import { wrap } from '../terminal.js';
export const name = 'about';
export const title = 'About';
// Edit this function to customize your About page.
export function render({ config }) {
const persona = config.persona || {};
return wrap((persona.about || 'Tell visitors who you are.') + '\n\n' + (persona.bio || 'This is your space on the internet.')).join('\n');
}
-11
View File
@@ -1,11 +0,0 @@
import { color, wrap } from '../terminal.js';
export const name = 'home';
export const title = 'Home';
// Edit this function to customize the landing page.
export function render({ config, ownerOnline }) {
const p = config.persona || {};
const status = ownerOnline() ? color('green', 'Owner is online') : color(config.theme?.muted || 'brightBlack', 'Owner is offline');
return `${color(config.theme?.accent || 'yellow', `Welcome to ${p.name || 'my portfolio'}`)}\n\n${wrap(p.bio || 'You are exploring a portfolio over SSH. Type “help” to get started.').join('\n')}\n\n${status}\n\nCommands: home · pages · open <page> · chat · contact · clear · quit`;
}
-8
View File
@@ -1,8 +0,0 @@
import * as home from './home.js';
import * as about from './about.js';
import * as projects from './projects.js';
import * as links from './links.js';
// Add new page modules to this registry to make them available as commands.
export const pages = [home, about, projects, links];
export const pageMap = new Map(pages.map(page => [page.name, page]));
-10
View File
@@ -1,10 +0,0 @@
export const name = 'links';
export const title = 'Links';
// Edit these links directly, or move them into your config file.
export function render({ config }) {
return (config.pages?.links?.lines || [
'GitHub: https://github.com/yourname',
'Website: https://example.com'
]).join('\n');
}
-10
View File
@@ -1,10 +0,0 @@
export const name = 'projects';
export const title = 'Projects';
// Replace these entries with your own work, or load them from config.
export function render({ config }) {
return (config.pages?.projects?.lines || [
'Project One — what it does',
'Project Two — what you learned'
]).join('\n');
}
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import crypto from 'node:crypto';
import ssh2 from 'ssh2';
import { loadConfig, configPath } from './config.js';
import { color, clear, header, box } from './terminal.js';
import { createCommandRouter } from './commands.js';
import { configureTerminal, createTuiSession } from './tui.js';
const { Server } = ssh2;
const config = loadConfig();
const sessions = new Set();
const notifications = new Set();
const hostKey = path.resolve(config.host.hostKeyPath || 'data/host.key');
if (!fs.existsSync(hostKey)) { fs.mkdirSync(path.dirname(hostKey), { recursive: true }); fs.writeFileSync(hostKey, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs1', format: 'pem' })); }
function isAdmin(ctx) { return ctx.admin === true; }
function count() { return sessions.size; }
function sendNotification(message) { for (const socket of notifications) socket.write(JSON.stringify({ type: 'notification', message }) + '\n'); }
function prompt(ctx) { return `${color(config.theme?.primary || 'cyan', 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin] ') : ''} `; }
function renderHome(ctx) {
const p = config.persona || {}; const width = Math.max(40, ctx.cols || 80); const title = `${p.name || 'Your Name'}${p.tagline || ''}`;
const content = router.home(ctx).split('\n');
return `${clear}${header(title, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\r\n\r\n${box('Portfolio', content, width, config.theme)}\r\n\r\n`;
}
function renderPage(ctx, input, content = '') {
const width = Math.max(40, ctx.cols || 80); const [command, argument] = input.split(/\s+/); const pageName = command === 'open' ? argument : command;
const title = config.pages?.[pageName]?.title || (command ? command[0].toUpperCase() + command.slice(1) : 'Portfolio');
return `${clear}${header(`${config.persona?.name || 'Your Name'}${config.persona?.tagline || ''}`, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', config.persona?.location || '')}\r\n\r\n${box(title, String(content || '').split(/\r?\n/), width, config.theme)}\r\n\r\n`;
}
function refreshHeaders() { for (const s of sessions) s.tui?.updateHeader(header(`${config.persona?.name || 'Your Name'}${config.persona?.tagline || ''}`, `${count()} connected`, s.ctx.cols, config.theme?.primary, config.theme?.muted)); }
const router = createCommandRouter({ config, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.tui?.notify(color(config.theme?.accent || 'yellow', line)); } });
function handleSession(channel, ctx) {
ctx.channel = channel; const active = { channel, ctx, tui: null }; sessions.add(active); refreshHeaders();
configureTerminal(channel, config.persona?.name || 'SSH Portfolio');
active.tui = createTuiSession({ channel, ctx, router, prompt, renderHome, renderPage, onClose: () => { sessions.delete(active); refreshHeaders(); } });
active.tui.showHome();
}
const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => {
const ctx = { admin: false, channel: null, cols: 80, username: 'visitor' };
client.on('authentication', ctxAuth => {
ctx.username = ctxAuth.username || 'visitor';
if (ctxAuth.method === 'none') { ctxAuth.accept(); return; }
if (ctxAuth.method === 'publickey' && ctxAuth.key) {
const key = ctxAuth.key.data.toString('base64');
const allowed = (config.adminPublicKeys || []).some(entry => String(entry).trim().split(/\s+/).includes(key));
if (allowed && (!ctxAuth.signature || ctxAuth.key.verify(ctxAuth.blob, ctxAuth.signature, ctxAuth.hashAlgo))) { ctx.admin = true; ctxAuth.accept(); return; }
}
ctxAuth.reject(['none', 'publickey']);
}).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); sshSession.on('window-change', (acceptResize, rejectResize, resize) => { ctx.cols = resize.cols || ctx.cols; refreshHeaders(); }); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\r\n'); e.exit(0); e.end(); }); })).on('error', () => {});
});
server.listen(config.host.port, '0.0.0.0', () => console.log(`SSH portfolio listening on port ${config.host.port}; config: ${configPath}`));
net.createServer(socket => { notifications.add(socket); socket.on('data', () => {}); socket.on('close', () => notifications.delete(socket)); }).listen(config.host.notificationPort, '127.0.0.1');
-27
View File
@@ -1,27 +0,0 @@
export const ESC = '\x1b[';
export const colors = { cyan: 36, yellow: 33, green: 32, magenta: 35, blue: 34, white: 37, red: 31, brightBlack: 90 };
export const color = (name, text) => `\x1b[${colors[name] || colors.cyan}m${text}\x1b[0m`;
export const clear = '\x1b[0m\x1b[2J\x1b[3J\x1b[H';
export const crlf = text => String(text).replace(/\r?\n/g, '\r\n');
export const visibleLength = text => String(text).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').length;
export const truncate = (text, width) => Array.from(String(text)).slice(0, Math.max(0, width)).join('');
export function header(left, right, width, leftColor = 'cyan', rightColor = 'brightBlack') {
const safeWidth = Math.max(30, width || 80);
const safeRight = truncate(right, Math.max(0, safeWidth - 2));
const safeLeft = truncate(left, Math.max(1, safeWidth - safeRight.length - 2));
return `${color(leftColor, safeLeft)}${' '.repeat(Math.max(1, safeWidth - safeLeft.length - safeRight.length))}${color(rightColor, safeRight)}`;
}
export function box(title, lines, width, theme = {}) {
const outer = Math.max(30, width || 80); const inner = outer - 4;
const primary = theme.primary || 'cyan'; const muted = theme.muted || 'brightBlack';
const titleText = ` ${truncate(title, inner - 2)} `; const topFill = Math.max(0, outer - titleText.length - 2);
let output = `${color(muted, '╭─')}${color(primary, titleText)}${color(muted, `${'─'.repeat(topFill)}`)}\r\n`;
for (const source of lines) {
const wrapped = wrap(source, inner);
for (const line of wrapped) output += `${color(muted, '│')} ${line}${' '.repeat(Math.max(0, inner - visibleLength(line)))} ${color(muted, '│')}\r\n`;
}
return output + color(muted, `${'─'.repeat(outer - 2)}`);
}
export function wrap(text, width = 78) {
const out = []; for (const paragraph of String(text).split('\n')) { if (!paragraph) { out.push(''); continue; } let line = ''; for (const word of paragraph.split(/\s+/)) { if (line && visibleLength(line) + visibleLength(word) + 1 > width) { out.push(line); line = word; } else line += (line ? ' ' : '') + word; } out.push(line); } return out;
}
-53
View File
@@ -1,53 +0,0 @@
import { color, crlf } from './terminal.js';
export function configureTerminal(stream, title) {
stream.write('\x1b[?2004h\x1b[?7h\x1b[?25h\x1b[?1h\x1b=');
stream.write(`\x1b]2;${String(title).replace(/[\x00-\x1f\x7f]/g, '')}\x07`);
}
export function createTuiSession({ channel, ctx, router, renderHome, renderPage, prompt, onClose }) {
const state = { input: '', cursor: 0, history: [], historyIndex: 0, closed: false };
const redrawInput = () => {
if (state.closed) return;
channel.write(`\r\x1b[2K${prompt(ctx)}${state.input}`);
if (state.cursor < state.input.length) channel.write(`\x1b[${state.input.length - state.cursor}D`);
};
const insert = text => {
const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, '');
if (!clean) return; state.input = state.input.slice(0, state.cursor) + clean + state.input.slice(state.cursor); state.cursor += clean.length; redrawInput();
};
const showHome = () => { if (!state.closed) channel.write(renderHome(ctx) + prompt(ctx) + state.input); };
const notify = text => { if (!state.closed) { channel.write(`\r\x1b[2K${crlf(text)}\r\n`); redrawInput(); } };
const updateHeader = text => { if (!state.closed) channel.write(`\x1b7\x1b[1;1H\x1b[2K${text}\x1b8`); };
const submit = () => {
const input = state.input.trim(); channel.write('\r\n');
if (input) { if (state.history.at(-1) !== input) state.history.push(input); if (state.history.length > 100) state.history.shift(); }
state.historyIndex = state.history.length; state.input = ''; state.cursor = 0;
const name = input.split(/\s+/)[0]?.toLowerCase() || 'home';
if (name === 'home' || name === 'clear') { router.execute(ctx, input); showHome(); return; }
const output = router.execute(ctx, input); if (state.closed || channel.destroyed) return;
channel.write(renderPage(ctx, input, output) + prompt(ctx));
};
const history = direction => {
if (!state.history.length) return;
state.historyIndex = Math.max(0, Math.min(state.history.length, state.historyIndex + direction));
state.input = state.historyIndex === state.history.length ? '' : state.history[state.historyIndex]; state.cursor = state.input.length; redrawInput();
};
const move = delta => { const next = Math.max(0, Math.min(state.input.length, state.cursor + delta)); if (next !== state.cursor) { channel.write(delta < 0 ? `\x1b[${state.cursor - next}D` : `\x1b[${next - state.cursor}C`); state.cursor = next; } };
channel.on('data', data => {
let key = data.toString();
if (key.includes('\x1b[200~')) { insert(key); return; }
while (key.length) {
const sequence = ['\x1b[A', '\x1bOA', '\x1b[B', '\x1bOB', '\x1b[C', '\x1bOC', '\x1b[D', '\x1bOD'].find(item => key.startsWith(item));
if (sequence) { if (sequence.endsWith('A')) history(-1); else if (sequence.endsWith('B')) history(1); else if (sequence.endsWith('C')) move(1); else move(-1); key = key.slice(sequence.length); continue; }
const char = key[0]; key = key.slice(1);
if (char === '\r' || char === '\n') { if (char === '\r' && key[0] === '\n') key = key.slice(1); submit(); }
else if (char === '\x03' || (char === '\x04' && !state.input)) channel.end();
else if (char === '\x7f' || char === '\b') { if (state.cursor > 0) { state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor); state.cursor--; redrawInput(); } }
else if (char === '\x01') move(-state.cursor); else if (char === '\x05') move(state.input.length - state.cursor);
else if (char >= ' ') insert(char);
}
});
channel.on('close', () => { state.closed = true; onClose?.(); });
return { showHome, notify, redrawInput, updateHeader, state };
}