From e499ae5adc969f07f321482bbdbb325c71dd4a54 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 27 Jul 2026 09:27:57 +0900 Subject: [PATCH] Modularize portfolio commands and owner chat --- README.md | 6 +++--- src/commands.js | 40 ++++++++++++++++++++++++++++++++++++++++ src/server.js | 30 ++++++------------------------ 3 files changed, 49 insertions(+), 27 deletions(-) create mode 100644 src/commands.js diff --git a/README.md b/README.md index 0eda2de..935ba08 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 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. +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 @@ -23,8 +23,8 @@ Because this is an intentionally open service, run it in a container or restrict ## 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. +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 `. ## Extending -Pages are data-driven in `config.json`; commands live in `src/server.js` and can be split into modules as the portfolio grows. Pong is intentionally left as the next module: it can use the same session registry and owner-online check. +Pages are data-driven in `config.json`; commands are isolated in `src/commands.js`. Pong is intentionally left as the next module: it can use the same session registry and owner-online check. diff --git a/src/commands.js b/src/commands.js new file mode 100644 index 0000000..fa5d0a3 --- /dev/null +++ b/src/commands.js @@ -0,0 +1,40 @@ +import { color, wrap } from './terminal.js'; + +export function createCommandRouter(api) { + const commands = new Map(); + const add = command => commands.set(command.name, command); + const page = name => { + const item = api.config.pages?.[name]; + if (!item) return `${color('red', `No page named “${name}”.`)}\nTry: pages`; + return `${color(api.config.theme?.accent || 'yellow', item.title || name)}\n${wrap((item.lines || []).join('\n')).join('\n')}`; + }; + + add({ name: 'home', description: 'Return home', run: () => { + const p = api.config.persona || {}; + const status = api.ownerOnline() ? color('green', 'Owner is online') : color(api.config.theme?.muted || 'brightBlack', 'Owner is offline'); + return `${color(api.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 · chat · contact · clear · quit`; + }}); + add({ name: 'help', description: 'Show this help', run: ctx => [...commands.values()].filter(c => !c.admin || api.isAdmin(ctx)).map(c => `${c.name.padEnd(18)} ${c.description}`).join('\n') }); + add({ name: 'pages', description: 'List available pages', run: () => Object.entries(api.config.pages || {}).map(([name, item]) => `${color(api.config.theme?.accent || 'yellow', name.padEnd(14))} ${item.title || ''}`).join('\n') || 'No pages configured.' }); + add({ name: 'open', description: 'Open a page', run: (ctx, arg) => arg ? page(arg) : 'Usage: open ' }); + add({ name: 'contact', description: 'Show contact details', run: () => `Email: ${api.config.persona?.email || 'not configured'}` }); + add({ name: 'chat', description: 'Chat with the owner/visitors', run: (ctx, arg) => { + if (!arg) return 'Usage: chat '; + 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.'; + }}); + add({ name: 'clear', description: 'Clear the screen', run: () => '' }); + add({ name: 'quit', description: 'Disconnect', run: ctx => { ctx.channel?.end(); return ''; } }); + add({ name: 'reload', description: 'Reload configuration', admin: true, run: () => { api.reload(); return 'Configuration reloaded.'; } }); + add({ name: 'theme', description: 'Set runtime primary color', admin: true, run: (ctx, arg) => { if (!arg) return 'Usage: theme '; api.config.theme.primary = arg; return `Primary color set to ${arg} (runtime only).`; } }); + add({ name: 'announce', description: 'Broadcast an announcement', admin: true, run: (ctx, arg) => { if (!arg) return 'Usage: announce '; api.broadcast(`OWNER: ${arg}`); api.sendNotification(`OWNER: ${arg}`); return 'Announcement sent.'; } }); + + return { + home: ctx => commands.get('home').run(ctx, ''), + execute(ctx, input) { + const [name, ...rest] = input.trim().split(/\s+/); const command = commands.get(name || 'home'); + if (!command || (command.admin && !api.isAdmin(ctx))) return `Unknown command: ${name}. Try “help”.`; + return command.run(ctx, rest.join(' ')); + } + }; +} diff --git a/src/server.js b/src/server.js index 104084c..9a76be5 100644 --- a/src/server.js +++ b/src/server.js @@ -5,7 +5,8 @@ import net from 'node:net'; import crypto from 'node:crypto'; import ssh2 from 'ssh2'; import { loadConfig, configPath } from './config.js'; -import { color, clear, wrap } from './terminal.js'; +import { color, clear } from './terminal.js'; +import { createCommandRouter } from './commands.js'; const { Server } = ssh2; @@ -23,29 +24,10 @@ function render(ctx, body = '') { const title = `● ${p.name || 'Your Name'} — ${p.tagline || ''}`; const users = `${count()} connected`; const gap = ' '.repeat(Math.max(2, (ctx.cols || 80) - title.length - users.length)); return `${clear}${color(primary, title)}${gap}${color(config.theme?.muted || 'brightBlack', users)}\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\n\n${body}\n\n${color(primary, 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin]') : ''}> `; } -function page(ctx, name) { - const p = config.pages?.[name]; if (!p) return `${color('red', `No page named “${name}”.`)}\nTry: pages`; - return `${color(config.theme?.accent || 'yellow', p.title || name)}\n${wrap((p.lines || []).join('\n')).join('\n')}`; -} -function home(ctx) { const p = config.persona || {}; 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\nCommands: home · pages · open · chat · contact · clear · quit`; } -function help(ctx) { return `help Show this help\nhome Return home\npages List available pages\nopen Open a page\nchat Chat with the owner/visitors\ncontact Show contact details\nclear Clear the screen\nquit Disconnect${isAdmin(ctx) ? '\n\nAdmin: reload · theme · announce ' : ''}`; } -function execute(ctx, input) { - const [command, ...rest] = input.trim().split(/\s+/); const arg = rest.join(' '); - if (!command) return home(ctx); - if (command === 'help') return help(ctx); if (command === 'home') return home(ctx); if (command === 'clear') return ''; - if (command === 'pages') return Object.entries(config.pages || {}).map(([k, v]) => `${color(config.theme?.accent || 'yellow', k.padEnd(14))} ${v.title || ''}`).join('\n') || 'No pages configured.'; - if (command === 'open') return arg ? page(ctx, arg) : 'Usage: open '; - if (command === 'contact') return `Email: ${config.persona?.email || 'not configured'}`; - if (command === 'chat') { if (!arg) return 'Usage: chat '; const author = isAdmin(ctx) ? `${config.persona?.name || 'Owner'} [owner]` : (ctx.username || 'visitor'); const line = `${author}: ${arg}`; for (const s of sessions) s.channel.write(`\r\n${color(config.theme?.accent || 'yellow', line)}\r\n` + render(s.ctx)); sendNotification(line); return 'Message sent.'; } - if (command === 'reload' && isAdmin(ctx)) { Object.assign(config, loadConfig()); return 'Configuration reloaded.'; } - if (command === 'theme' && isAdmin(ctx) && arg) { config.theme.primary = arg; return `Primary color set to ${arg} (runtime only; edit config to persist).`; } - if (command === 'announce' && isAdmin(ctx) && arg) { for (const s of sessions) s.channel.write(`\r\n${color(config.theme?.accent || 'yellow', `OWNER: ${arg}`)}\r\n` + render(s.ctx)); sendNotification(`OWNER: ${arg}`); return 'Announcement sent.'; } - if (command === 'quit' || command === 'exit') { ctx.channel.end(); return ''; } - return `Unknown command: ${command}. Try “help”.`; -} +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.channel.write(`\r\n${color(config.theme?.accent || 'yellow', line)}\r\n` + render(s.ctx)); } }); function handleSession(channel, ctx) { - let buffer = ''; ctx.channel = channel; const active = { channel, ctx }; sessions.add(active); channel.write(render(ctx, home(ctx))); - channel.on('data', data => { for (const byte of data.toString()) { if (byte === '\r' || byte === '\n') { channel.write(`\r\n${execute(ctx, buffer)}\n` + render(ctx)); buffer = ''; } else if (byte === '\x7f') { if (buffer) { buffer = buffer.slice(0, -1); channel.write('\b \b'); } } else if (byte >= ' ') { buffer += byte; channel.write(byte); } } }); + let buffer = ''; ctx.channel = channel; const active = { channel, ctx }; sessions.add(active); channel.write(render(ctx, router.home(ctx))); + channel.on('data', data => { for (const byte of data.toString()) { if (byte === '\r' || byte === '\n') { channel.write(`\r\n${router.execute(ctx, buffer)}\n` + render(ctx)); buffer = ''; } else if (byte === '\x7f') { if (buffer) { buffer = buffer.slice(0, -1); channel.write('\b \b'); } } else if (byte >= ' ') { buffer += byte; channel.write(byte); } } }); channel.on('close', () => sessions.delete(active)); } const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => { @@ -59,7 +41,7 @@ const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-po 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('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((execute(ctx, info.command) || '') + '\n'); e.exit(0); e.end(); }); })).on('error', () => {}); + }).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\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');