#!/usr/bin/env node /* * BotFork Shortcut * * Intentionally named .txt so it doesn't look like a program. It is a * real, executable Node.js script, invoked by the botfork bin wrapper. * * Fully automatic — one command, nothing to configure: * botfork build (if needed) + start the server + connect. * When you quit, the server stops automatically. * botfork stop stop the server (manual) * botfork status is everything up? (manual) * botfork version|help * * Server assets live in ~/.cache/botfork (binary, PID, log); the * Ed25519 host key is generated once in ~/.config/botfork/hostkey. */ 'use strict'; const { spawn, spawnSync } = require('node:child_process'); const { readFileSync, writeFileSync, existsSync, mkdirSync, openSync, unlinkSync } = require('node:fs'); const { join } = require('node:path'); const { homedir } = require('node:os'); const net = require('node:net'); const VERSION = '0.2.18'; const CFG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'botfork'); const HOSTKEY_FILE = join(CFG_DIR, 'hostkey'); const KNOWN_HOSTS = join(CFG_DIR, 'known_hosts'); const CACHE_DIR = join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'botfork'); const PID_FILE = join(CACHE_DIR, 'server.pid'); const LOG_FILE = join(CACHE_DIR, 'server.log'); const BIN_FILE = join(CACHE_DIR, 'botfork'); const HOST = process.env.BOTFORK_HOST || 'localhost'; const PORT = parseInt(process.env.BOTFORK_PORT || '2222', 10); const USER = process.env.BOTFORK_USER || 'app'; const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', cyan: '\x1b[36m', magenta: '\x1b[35m', white: '\x1b[37m', }; function log(color, msg) { console.log(`${color}${msg}${COLORS.reset}`); } function logError(msg) { log(COLORS.red + COLORS.bold, ` ✗ ${msg}`); } function logSuccess(msg) { log(COLORS.green + COLORS.bold, ` ✓ ${msg}`); } function logInfo(msg) { log(COLORS.cyan, ` ℹ ${msg}`); } function logWarn(msg) { log(COLORS.yellow, ` ⚠ ${msg}`); } function fail(msg) { logError(msg); process.exit(1); } // ---- server management ---------------------------------------------------- // Bundled Go source ships inside the npm package, so the shortcut is // self-contained: it can always build the exact version it was published // with, regardless of whether the repo exists locally. const PKG_SRC = join(__dirname, 'server-src'); function serverDir() { if (process.env.BOTFORK_SERVER_DIR) return process.env.BOTFORK_SERVER_DIR; if (existsSync(join(PKG_SRC, 'go.mod'))) return PKG_SRC; const cwd = process.cwd(); if (existsSync(join(cwd, 'go.mod'))) { const mod = readFileSync(join(cwd, 'go.mod'), 'utf8'); if (/github\.com\/xdbi\/botfork/.test(mod)) return cwd; } return null; } function buildBinary() { const dir = serverDir(); if (!dir) { fail('cannot build the server: run inside the botfork repo or set BOTFORK_SERVER_DIR'); } mkdirSync(CACHE_DIR, { recursive: true }); logInfo('building the server (first run)...'); const build = spawnSync('go', ['build', '-o', BIN_FILE, './cmd/botfork/'], { cwd: dir, stdio: 'inherit', encoding: 'utf8', }); if (build.status !== 0) fail('build failed (see output above)'); } // source files that make up the server binary const SERVER_SRC = [ 'cmd/botfork/main.go', 'internal/server/server.go', 'internal/cli/cli.go', 'internal/keyutil/keyutil.go', ]; function newestSourceMtime() { const dir = serverDir(); if (!dir) return 0; let max = 0; for (const rel of SERVER_SRC) { try { const m = Number(spawnSync('stat', ['-c', '%Y', join(dir, rel)], { encoding: 'utf8' }).stdout.trim()); if (m > max) max = m; } catch {} } return max; } function ensureBinary() { if (!existsSync(BIN_FILE)) return buildBinary(); const binM = Number(spawnSync('stat', ['-c', '%Y', BIN_FILE], { encoding: 'utf8' }).stdout.trim() || 0); const srcM = newestSourceMtime(); if (srcM > binM) { logInfo('source is newer — rebuilding'); buildBinary(); } } function ensureHostKey() { if (existsSync(HOSTKEY_FILE) && readFileSync(HOSTKEY_FILE, 'utf8').trim()) return; const dir = serverDir(); if (!dir) { fail('cannot generate a host key: run inside the botfork repo or set BOTFORK_SERVER_DIR'); } logInfo('generating a stable SSH host key...'); const out = spawnSync('go', ['run', './cmd/genkey'], { cwd: dir, encoding: 'utf8' }); if (out.status !== 0) fail('failed to generate the host key'); const line = out.stdout.split('\n').find(l => l.startsWith('SSH_HOST_KEY_B64=')); if (!line) fail('host key generator returned nothing usable'); mkdirSync(CFG_DIR, { recursive: true }); writeFileSync(HOSTKEY_FILE, line.slice('SSH_HOST_KEY_B64='.length).trim() + '\n', { mode: 0o600 }); } function readPid() { if (!existsSync(PID_FILE)) return null; try { const pid = parseInt(readFileSync(PID_FILE, 'utf8').trim(), 10); return pid && pid > 1 ? pid : null; } catch { return null; } } function pidAlive(pid) { if (!pid) return false; // on Linux, a zombie (state Z) is still visible to kill(pid, 0) but is // already dead — treat it as gone. try { const stat = readFileSync(`/proc/${pid}/stat`, 'utf8'); const state = stat.slice(stat.lastIndexOf(')') + 2).split(' ')[0]; return state !== 'Z'; } catch { try { process.kill(pid, 0); return true; } catch { return false; } } } function isRunning() { const pid = readPid(); return pidAlive(pid) ? pid : null; } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function startServer() { return new Promise(async (resolve) => { if (isRunning()) return resolve(false); // already up — don't claim the session ensureHostKey(); ensureBinary(); mkdirSync(CACHE_DIR, { recursive: true }); const out = openSync(LOG_FILE, 'a+'); const err = openSync(LOG_FILE, 'a+'); const child = spawn(BIN_FILE, [], { detached: true, stdio: ['ignore', out, err], env: { ...process.env, ADDR: `:${PORT}`, SSH_HOST_KEY_B64: readFileSync(HOSTKEY_FILE, 'utf8').trim() }, }); child.unref(); writeFileSync(PID_FILE, String(child.pid) + '\n'); let ok = false; for (let i = 0; i < 100; i++) { if (!pidAlive(child.pid)) break; if (await portOpen('127.0.0.1', PORT, 150)) { ok = true; break; } await sleep(100); } if (!ok) { logError('server did not come up — last log lines:'); if (existsSync(LOG_FILE)) { log(COLORS.dim, readFileSync(LOG_FILE, 'utf8').trim().split('\n').slice(-15).join('\n')); } try { process.kill(child.pid, 'SIGKILL'); } catch {} process.exit(1); } resolve(true); }); } function stopServer() { const pid = isRunning(); if (!pid) { logInfo('server is not running'); return; } logInfo(`stopping server (pid ${pid})...`); try { process.kill(pid, 'SIGTERM'); } catch {} let waited = 0; while (pidAlive(pid) && waited < 4000) { spawnSync('sleep', ['0.2']); waited += 200; } if (pidAlive(pid)) { logWarn('SIGTERM ignored — sending SIGKILL'); try { process.kill(pid, 'SIGKILL'); } catch {} spawnSync('sleep', ['0.2']); } try { unlinkSync(PID_FILE); } catch {} if (pidAlive(pid)) { logError('could not stop the server process'); process.exit(1); } logSuccess('server stopped'); } function portOpen(host, port, timeout) { return new Promise(resolve => { const s = net.connect(port, host); const done = v => { s.destroy(); resolve(v); }; s.setTimeout(timeout || 2000); s.once('connect', () => done(true)); s.once('timeout', () => done(false)); s.once('error', () => done(false)); }); } // ---- connecting (ssh) ----------------------------------------------------- async function connect() { const args = [ '-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${KNOWN_HOSTS}`, '-o', 'ConnectTimeout=10', '-p', String(PORT), `${USER}@${HOST}`, ]; let clearedKey = false; for (let attempt = 1; ; attempt++) { let stderrBuf = ''; const child = spawn(process.env.BOTFORK_SSH_BIN || 'ssh', args, { stdio: ['inherit', 'inherit', 'pipe'], shell: false }); child.stderr.on('data', d => { stderrBuf += d.toString(); }); const result = await new Promise(resolve => { child.on('error', err => resolve({ ok: false, code: 1, err })); child.on('exit', code => resolve({ ok: code === 0, code })); }); if (result.ok) return; if (result.code === 255 && /REMOTE HOST IDENTIFICATION HAS CHANGED/i.test(stderrBuf) && !clearedKey) { clearedKey = true; logWarn('host key changed — clearing cached key and retrying...'); try { spawnSync('ssh-keygen', ['-f', KNOWN_HOSTS, '-R', `[${HOST}]:${PORT}`], { stdio: 'inherit' }); } catch {} continue; } logError(`connection failed (${result.err ? result.err.message : 'exit ' + result.code})`); process.exit(result.code == null ? 1 : result.code); } } // ---- ui ------------------------------------------------------------------- function showVersion() { log(COLORS.magenta + COLORS.bold, `\n botfork v${VERSION}`); log(COLORS.dim, ' one command · server + CLI over SSH\n'); } function showHelp() { log(COLORS.magenta + COLORS.bold, '\n \\ | /\n \\ | /\n \\ | /\n-----------(*)-----------\n / | \\\n / | \\\n / | \\'); log(COLORS.magenta + COLORS.bold, '\n botfork v' + VERSION); log(COLORS.dim, ' one command · server + CLI over SSH\n'); log(COLORS.white, ' botfork everything automatic'); log(COLORS.white, ' botfork stop stop the server'); log(COLORS.white, ' botfork status show server state'); log(COLORS.white, ' botfork version show version'); log(COLORS.white, ' botfork help this help\n'); log(COLORS.cyan + COLORS.bold, ' Environment:'); log(COLORS.white, ' BOTFORK_HOST host (default: localhost)'); log(COLORS.white, ' BOTFORK_PORT port (default: 2222)'); log(COLORS.white, ' BOTFORK_USER ssh user (default: app)'); log(COLORS.white, ' BOTFORK_SERVER_DIR repo dir used to build the server'); log(COLORS.white, ' BOTFORK_SSH_BIN custom ssh binary\n'); } // --- entry --- const cmd = process.argv[2]; if (cmd === 'version' || cmd === '-v' || cmd === '--version') { showVersion(); process.exit(0); } if (cmd === 'help' || cmd === '-h' || cmd === '--help') { showHelp(); process.exit(0); } if (cmd === 'stop') { stopServer(); process.exit(0); } if (cmd === 'status') { const running = isRunning(); if (running) logSuccess(`server is running (pid ${running})`); else logInfo('server is not running'); (async () => { const reachable = await portOpen(HOST, PORT); if (reachable) logSuccess(`${HOST}:${PORT} is reachable`); else logWarn(`${HOST}:${PORT} is unreachable`); process.exit((running && reachable) ? 0 : 1); })(); return; } // default: automatic full session (async () => { const claimed = await startServer(); if (process.env.BOTFORK_NO_CONNECT !== '1') { await connect(); } if (claimed) { logInfo('session ended — stopping the server'); stopServer(); } })();