UNPKG

@abyrd9/harbor-cli

Version:

A CLI tool for orchestrating local development services in a tmux session. Perfect for microservices and polyglot projects with automatic service discovery and before/after script support.

1,261 lines (1,243 loc) 50.2 kB
#!/usr/bin/env node import { Command } from '@commander-js/extra-typings'; import fs from 'node:fs'; import path from 'node:path'; import { spawn, exec } from 'node:child_process'; import { chmodSync } from 'node:fs'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { randomUUID } from 'node:crypto'; import os from 'node:os'; import readline from 'node:readline'; import pc from 'picocolors'; const execAsync = promisify(exec); // Colored output helpers const log = { error: (msg) => console.log(`${pc.red('✗')} ${msg}`), success: (msg) => console.log(`${pc.green('✓')} ${msg}`), info: (msg) => console.log(`${pc.blue('ℹ')} ${msg}`), warn: (msg) => console.log(`${pc.yellow('⚠')} ${msg}`), step: (msg) => console.log(`${pc.cyan('→')} ${msg}`), dim: (msg) => console.log(pc.dim(msg)), plain: (msg) => console.log(msg), header: (msg) => console.log(`\n${pc.bold(msg)}`), cmd: (msg) => console.log(` ${pc.dim('$')} ${pc.cyan(msg)}`), label: (label, value) => console.log(` ${pc.dim(label)} ${value}`), }; // Read version from package.json const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageJson = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8')); const requiredDependencies = [ { name: 'tmux', command: 'tmux -V', installMsg: 'https://github.com/tmux/tmux/wiki/Installing', requiredFor: 'terminal multiplexing', }, { name: 'jq', command: 'jq --version', installMsg: 'https://stedolan.github.io/jq/download/', requiredFor: 'JSON processing in service management', }, ]; function detectOS() { const platform = os.platform(); const arch = os.arch(); const isWindows = platform === 'win32'; const isMac = platform === 'darwin'; const isLinux = platform === 'linux'; // Check if running in WSL let isWSL = false; if (isLinux) { try { const release = fs.readFileSync('/proc/version', 'utf-8'); isWSL = release.toLowerCase().includes('microsoft') || release.toLowerCase().includes('wsl'); } catch { // If we can't read /proc/version, assume not WSL } } return { platform, arch, isWindows, isMac, isLinux, isWSL, }; } function getInstallInstructions(dependency, osInfo) { const instructions = []; if (dependency === 'tmux') { if (osInfo.isMac) { instructions.push('🍎 macOS:'); instructions.push(' • Using Homebrew: brew install tmux'); instructions.push(' • Using MacPorts: sudo port install tmux'); instructions.push(' • Manual: https://github.com/tmux/tmux/wiki/Installing'); } else if (osInfo.isLinux) { if (osInfo.isWSL) { instructions.push('🐧 WSL/Linux:'); instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install tmux'); instructions.push(' • CentOS/RHEL: sudo yum install tmux'); instructions.push(' • Fedora: sudo dnf install tmux'); instructions.push(' • Arch: sudo pacman -S tmux'); instructions.push(' • openSUSE: sudo zypper install tmux'); } else { instructions.push('🐧 Linux:'); instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install tmux'); instructions.push(' • CentOS/RHEL: sudo yum install tmux'); instructions.push(' • Fedora: sudo dnf install tmux'); instructions.push(' • Arch: sudo pacman -S tmux'); instructions.push(' • openSUSE: sudo zypper install tmux'); } } else if (osInfo.isWindows) { instructions.push('🪟 Windows:'); instructions.push(' • Using Chocolatey: choco install tmux'); instructions.push(' • Using Scoop: scoop install tmux'); instructions.push(' • Using WSL: Install in WSL and use from there'); instructions.push(' • Manual: https://github.com/tmux/tmux/wiki/Installing'); } } else if (dependency === 'jq') { if (osInfo.isMac) { instructions.push('🍎 macOS:'); instructions.push(' • Using Homebrew: brew install jq'); instructions.push(' • Using MacPorts: sudo port install jq'); instructions.push(' • Using Fink: fink install jq'); instructions.push(' • Manual: https://jqlang.org/download/'); } else if (osInfo.isLinux) { if (osInfo.isWSL) { instructions.push('🐧 WSL/Linux:'); instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install jq'); instructions.push(' • CentOS/RHEL: sudo yum install jq'); instructions.push(' • Fedora: sudo dnf install jq'); instructions.push(' • Arch: sudo pacman -S jq'); instructions.push(' • openSUSE: sudo zypper install jq'); } else { instructions.push('🐧 Linux:'); instructions.push(' • Ubuntu/Debian: sudo apt update && sudo apt install jq'); instructions.push(' • CentOS/RHEL: sudo yum install jq'); instructions.push(' • Fedora: sudo dnf install jq'); instructions.push(' • Arch: sudo pacman -S jq'); instructions.push(' • openSUSE: sudo zypper install jq'); } } else if (osInfo.isWindows) { instructions.push('🪟 Windows:'); instructions.push(' • Using winget: winget install jqlang.jq'); instructions.push(' • Using Chocolatey: choco install jq'); instructions.push(' • Using Scoop: scoop install jq'); instructions.push(' • Using WSL: Install in WSL and use from there'); instructions.push(' • Manual: https://jqlang.org/download/'); } } return instructions; } async function checkDependencies() { const missingDeps = []; const osInfo = detectOS(); for (const dep of requiredDependencies) { try { await new Promise((resolve, reject) => { const process = spawn('sh', ['-c', dep.command]); process.on('close', (code) => { if (code === 0) resolve(null); else reject(); }); }); } catch { missingDeps.push(dep); } } if (missingDeps.length > 0) { log.error('Missing required dependencies'); log.plain(`\n${pc.dim('Detected OS:')} ${osInfo.platform} ${osInfo.arch}${osInfo.isWSL ? ' (WSL)' : ''}`); for (const dep of missingDeps) { log.plain(`\n${pc.yellow(dep.name)} ${pc.dim(`(required for ${dep.requiredFor})`)}`); const instructions = getInstallInstructions(dep.name, osInfo); if (instructions.length > 0) { log.plain(pc.dim(' Installation options:')); instructions.forEach(instruction => { log.plain(instruction); }); } else { log.plain(` ${pc.dim('Instructions:')} ${dep.installMsg}`); } } log.plain(`\n${pc.dim('After installing dependencies, run Harbor again.')}`); throw new Error('Please install missing dependencies before continuing'); } } // ───────────────────────────────────────────────────────────── // Inter-Pane Communication Functions // ───────────────────────────────────────────────────────────── const sleep = (ms) => new Promise(r => setTimeout(r, ms)); function getSessionInfo() { const sessionFile = path.join(process.cwd(), '.harbor', 'session.json'); if (!fs.existsSync(sessionFile)) return null; try { return JSON.parse(fs.readFileSync(sessionFile, 'utf-8')); } catch { return null; } } function checkAccess(target) { const session = getSessionInfo(); if (!session) { return { allowed: false, error: 'No harbor session running. Run "harbor launch" first.' }; } const targetService = session.services[target]; if (!targetService) { const available = Object.keys(session.services).join(', '); return { allowed: false, error: `Unknown service: ${target}. Available services: ${available}` }; } // If called from outside a harbor pane (no HARBOR_SERVICE env), allow access const callerService = process.env.HARBOR_SERVICE; if (!callerService) { return { allowed: true }; } // If called from within a harbor pane, check canAccess const callerInfo = session.services[callerService]; if (!callerInfo) { return { allowed: true }; // Caller not in session, allow } const canAccess = callerInfo.canAccess || []; if (!canAccess.includes(target)) { return { allowed: false, error: `Service "${callerService}" does not have access to "${target}". Add "${target}" to canAccess in your harbor config.` }; } return { allowed: true }; } async function sendToPane(target, command) { const session = getSessionInfo(); if (!session) throw new Error('No harbor session running'); const service = session.services[target]; if (!service) throw new Error(`Unknown service: ${target}`); const tmuxCmd = `tmux -L ${session.socket}`; const escaped = command.replace(/"/g, '\\"'); await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "${escaped}" Enter`); } async function capturePane(target, lines = 500) { const session = getSessionInfo(); if (!session) throw new Error('No harbor session running'); const service = session.services[target]; if (!service) throw new Error(`Unknown service: ${target}`); const tmuxCmd = `tmux -L ${session.socket}`; const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -${lines}`); return stdout; } async function execInPane(target, command, timeout = 3000) { const session = getSessionInfo(); if (!session) throw new Error('No harbor session running'); const service = session.services[target]; if (!service) throw new Error(`Unknown service: ${target}`); const tmuxCmd = `tmux -L ${session.socket}`; const markerId = randomUUID().slice(0, 8); const startMarker = `<<<HARBOR_START_${markerId}>>>`; const endMarker = `<<<HARBOR_END_${markerId}>>>`; // Send start marker await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${startMarker}'" Enter`); await sleep(100); // Send command const escaped = command.replace(/'/g, "'\\''"); await execAsync(`${tmuxCmd} send-keys -t "${service.target}" '${escaped}' Enter`); await sleep(timeout); // Send end marker await execAsync(`${tmuxCmd} send-keys -t "${service.target}" "echo '${endMarker}'" Enter`); await sleep(200); // Capture and extract const { stdout } = await execAsync(`${tmuxCmd} capture-pane -t "${service.target}" -p -S -500`); // Extract content between markers const regex = new RegExp(`${escapeRegex(startMarker)}\\n([\\s\\S]*?)${escapeRegex(endMarker)}`); const match = stdout.match(regex); if (match) { // Clean up the output const rawOutput = match[1]; const lines = rawOutput.split('\n'); // Filter out the echoed command and prompts const cleanedLines = lines.filter(line => { const trimmed = line.trim(); if (!trimmed) return false; if (trimmed.includes(`echo '${startMarker}'`)) return false; if (trimmed.includes(`echo '${endMarker}'`)) return false; return true; }); return cleanedLines.join('\n').trim() || '(no output)'; } return stdout.trim() || '(no output)'; } function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // ───────────────────────────────────────────────────────────── // Configuration Prompts // ───────────────────────────────────────────────────────────── function promptConfigLocation() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { log.plain(`\n${pc.bold('Found package.json.')} Where would you like to store harbor config?`); log.plain(` ${pc.cyan('1.')} package.json ${pc.dim('(keeps everything in one place)')}`); log.plain(` ${pc.cyan('2.')} harbor.json ${pc.dim('(separate config file, auto-IntelliSense)')}\n`); const ask = () => { rl.question(`Enter choice ${pc.dim('(1 or 2)')}: `, (answer) => { const choice = answer.trim(); if (choice === '1') { rl.close(); resolve('package.json'); } else if (choice === '2') { rl.close(); resolve('harbor.json'); } else { log.warn('Please enter 1 or 2'); ask(); } }); }; ask(); }); } const possibleProjectFiles = [ 'package.json', // Node.js projects 'go.mod', // Go projects 'Cargo.toml', // Rust projects 'composer.json', // PHP projects 'requirements.txt', // Python projects 'Gemfile', // Ruby projects 'pom.xml', // Java Maven projects 'build.gradle', // Java Gradle projects ]; const program = new Command(); // Custom help formatting function showCustomHelp() { const dim = pc.dim; const bold = pc.bold; const cyan = pc.cyan; const yellow = pc.yellow; const green = pc.green; console.log(` ${bold('⚓ Harbor')} ${dim(`v${packageJson.version}`)} ${dim('Orchestrate local dev services in tmux')} ${yellow('Usage:')} ${dim('$')} harbor ${cyan('<command>')} ${dim('[options]')} ${yellow('Commands:')} ${green('dock')} Scan directories and create harbor.json config ${green('moor')} Add new services to existing config ${green('launch')} Start all services in tmux ${dim('(-d for headless)')} ${green('anchor')} Attach to a running Harbor session ${green('scuttle')} Stop all services ${green('bearings')} Show status of running services ${yellow('Inter-Pane Communication:')} ${green('hail')} Send a command to another service pane ${green('survey')} Capture output from a service pane ${green('parley')} Execute command and capture response ${yellow('Agent Awareness:')} ${green('whoami')} Show current pane identity and access ${green('context')} Output full session context (markdown) ${yellow('Quick Start:')} ${dim('$')} harbor dock ${dim('# Create config')} ${dim('$')} harbor launch ${dim('# Start services')} ${dim('$')} harbor launch -d ${dim('# Start headless')} ${yellow('Options:')} ${cyan('-V, --version')} Show version ${cyan('-h, --help')} Show help for command ${dim('Run')} harbor ${cyan('<command>')} --help ${dim('for detailed command info')} `); } program .name('harbor') .description('Orchestrate local dev services in tmux') .version(packageJson.version) .action(async () => await checkDependencies()) .addHelpCommand(false) .configureHelp({ sortSubcommands: false, sortOptions: false, }); // Override help display program.helpInformation = () => ''; program.on('--help', () => { }); // If no command is provided, display custom help if (process.argv.length <= 2) { showCustomHelp(); process.exit(0); } // Handle -h and --help for main command if (process.argv.includes('-h') || process.argv.includes('--help')) { if (process.argv.length === 3) { showCustomHelp(); process.exit(0); } } program.command('dock') .description('Scan directories and create harbor.json config') .option('-p, --path <path>', 'Directory to scan for service projects', './') .action(async (options) => { try { await checkDependencies(); const configExists = checkHasHarborConfig(); if (configExists) { log.error('Harbor project already initialized'); log.dim(' Configuration already exists'); log.plain(''); log.info('To reinitialize, remove the existing configuration first.'); process.exit(1); } await generateDevFile(options.path); log.plain(''); log.success(pc.green('Environment prepared!')); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); program.command('moor') .description('Add new services to existing config') .option('-p, --path <path>', 'Directory to scan for new service projects', './') .action(async (options) => { try { await checkDependencies(); if (!checkHasHarborConfig()) { log.error('No harbor configuration found'); log.plain(''); log.info('To initialize a new Harbor project:'); log.cmd('harbor dock'); process.exit(1); } await generateDevFile(options.path); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); program.command('launch') .description('Start all services in tmux (-d for headless)') .option('-d, --detach', 'Run in background (headless mode)') .option('--headless', 'Alias for --detach') .option('--name <name>', 'Override tmux session name') .action(async (options) => { try { const isDetached = options.detach || options.headless; // Check if already inside a tmux session (only matters for attached mode) if (!isDetached && process.env.TMUX) { log.error('Cannot launch in attached mode from inside a tmux session'); log.plain(''); log.info('Options:'); log.plain(` ${pc.cyan('1.')} Use headless mode: ${pc.cyan('harbor launch -d')}`); log.plain(` ${pc.cyan('2.')} Detach from current session ${pc.dim('(Ctrl+b then d)')} and try again`); process.exit(1); } await checkDependencies(); await runServices({ detach: isDetached, name: options.name }); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); program.command('anchor') .description('Attach to a running Harbor session') .option('--name <name>', 'Session name to attach to') .action(async (options) => { try { // Check if already inside a tmux session if (process.env.TMUX) { log.error('Cannot anchor from inside a tmux session'); log.plain(''); log.info('You are already inside a tmux session. To attach:'); log.plain(` ${pc.cyan('1.')} Detach from current session ${pc.dim('(Ctrl+b then d)')}`); log.plain(` ${pc.cyan('2.')} Run ${pc.cyan('harbor anchor')} from a regular terminal`); process.exit(1); } const config = await readHarborConfig(); const sessionName = options.name || config.sessionName || 'harbor'; const socketName = `harbor-${sessionName}`; // Check if session exists const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], { stdio: 'pipe', }); await new Promise((resolve) => { checkSession.on('close', (code) => { if (code !== 0) { log.error(`No running Harbor session found ${pc.dim(`(looking for: ${sessionName})`)}`); log.plain(''); log.info('To start services:'); log.cmd('harbor launch'); process.exit(1); } resolve(); }); }); // Attach to the session const attach = spawn('tmux', ['-L', socketName, 'attach-session', '-t', sessionName], { stdio: 'inherit', }); attach.on('close', async (code) => { // Check if session was killed (vs just detached) const checkAfter = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], { stdio: 'pipe', }); const sessionStillExists = await new Promise((resolve) => { checkAfter.on('close', (checkCode) => { resolve(checkCode === 0); }); }); // If session no longer exists, it was killed - run after scripts if (!sessionStillExists && config.after && config.after.length > 0) { try { await execute(config.after, 'after'); } catch { log.error('After scripts failed'); process.exit(1); } } process.exit(code || 0); }); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); program.command('scuttle') .description('Stop all services and kill the session') .option('--name <name>', 'Session name to stop') .action(async (options) => { try { const config = await readHarborConfig(); const sessionName = options.name || config.sessionName || 'harbor'; const socketName = `harbor-${sessionName}`; // Check if session exists const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], { stdio: 'pipe', }); const sessionExists = await new Promise((resolve) => { checkSession.on('close', (code) => { resolve(code === 0); }); }); if (!sessionExists) { log.info(`No running Harbor session found ${pc.dim(`(looking for: ${sessionName})`)}`); process.exit(0); } // Kill the session const killSession = spawn('tmux', ['-L', socketName, 'kill-session', '-t', sessionName], { stdio: 'inherit', }); killSession.on('close', async (code) => { if (code === 0) { log.success(`Harbor session ${pc.cyan(sessionName)} stopped`); // Clean up session.json const sessionFile = path.join(process.cwd(), '.harbor', 'session.json'); if (fs.existsSync(sessionFile)) { fs.unlinkSync(sessionFile); log.dim(' Cleaned up session metadata'); } // Execute after scripts when session is killed if (config.after && config.after.length > 0) { try { await execute(config.after, 'after'); } catch { log.error('After scripts failed'); process.exit(1); } } } else { log.error('Failed to stop Harbor session'); } process.exit(code || 0); }); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); program.command('bearings') .description('Show status of running services') .option('--name <name>', 'Session name to check') .action(async (options) => { try { const config = await readHarborConfig(); const sessionName = options.name || config.sessionName || 'harbor'; const socketName = `harbor-${sessionName}`; // Check if session exists const checkSession = spawn('tmux', ['-L', socketName, 'has-session', '-t', sessionName], { stdio: 'pipe', }); const sessionExists = await new Promise((resolve) => { checkSession.on('close', (code) => { resolve(code === 0); }); }); if (!sessionExists) { log.header(`${pc.cyan('⚓')} Harbor Status`); log.plain(''); log.label('Session:', sessionName); log.label('Status:', pc.yellow('Not running')); log.plain(''); log.info('To start services:'); log.cmd(`harbor launch ${pc.dim('# interactive mode')}`); log.cmd(`harbor launch -d ${pc.dim('# headless mode')}`); log.plain(''); process.exit(0); } // Get list of windows (services) const listWindows = spawn('tmux', ['-L', socketName, 'list-windows', '-t', sessionName, '-F', '#{window_index}|#{window_name}|#{pane_current_command}'], { stdio: ['pipe', 'pipe', 'pipe'], }); let windowOutput = ''; listWindows.stdout.on('data', (data) => { windowOutput += data.toString(); }); await new Promise((resolve) => { listWindows.on('close', () => resolve()); }); const windows = windowOutput.trim().split('\n').filter(Boolean); log.header(`${pc.cyan('⚓')} Harbor Status`); log.plain(''); log.label('Session:', sessionName); log.label('Status:', pc.green('Running ✓')); log.label('Windows:', String(windows.length)); log.plain(''); log.plain(` ${pc.dim('Services:')}`); for (const window of windows) { const [index, name] = window.split('|'); const logFile = `.harbor/${sessionName}-${name}.log`; const hasLog = fs.existsSync(path.join(process.cwd(), logFile)); const logIndicator = hasLog ? pc.dim(' 📄') : ''; log.plain(` ${pc.dim(`[${index}]`)} ${pc.cyan(name)}${logIndicator}`); } // Check for log files const harborDir = path.join(process.cwd(), '.harbor'); if (fs.existsSync(harborDir)) { const logFiles = fs.readdirSync(harborDir).filter(f => f.endsWith('.log')); if (logFiles.length > 0) { log.plain(''); log.plain(` ${pc.dim('Logs:')}`); for (const logFile of logFiles) { const logPath = path.join(harborDir, logFile); const stats = fs.statSync(logPath); const sizeKB = (stats.size / 1024).toFixed(1); log.plain(` ${pc.dim(`.harbor/${logFile}`)} ${pc.dim(`(${sizeKB} KB)`)}`); } } } log.plain(''); log.plain(` ${pc.dim('Commands:')}`); log.plain(` ${pc.cyan('harbor anchor')} ${pc.dim('Attach to the session')}`); log.plain(` ${pc.cyan('harbor scuttle')} ${pc.dim('Stop all services')}`); log.plain(''); } catch (err) { log.error(err instanceof Error ? err.message : 'Unknown error'); process.exit(1); } }); // ───────────────────────────────────────────────────────────── // Inter-Pane Communication Commands // ───────────────────────────────────────────────────────────── program.command('hail') .description('Send a command to another service pane') .argument('<service>', 'Target service name') .argument('<command>', 'Command to send') .action(async (service, command) => { try { const access = checkAccess(service); if (!access.allowed) { log.error(access.error || 'Access denied'); process.exit(1); } await sendToPane(service, command); log.success(`Hailed ${pc.cyan(service)}: ${pc.dim(command)}`); } catch (err) { log.error(err instanceof Error ? err.message : 'Failed to hail service'); process.exit(1); } }); program.command('survey') .description('Capture output from a service pane') .argument('<service>', 'Target service name') .option('-n, --lines <number>', 'Number of lines to capture', '500') .action(async (service, options) => { try { const access = checkAccess(service); if (!access.allowed) { log.error(access.error || 'Access denied'); process.exit(1); } const output = await capturePane(service, parseInt(options.lines)); console.log(output); } catch (err) { log.error(err instanceof Error ? err.message : 'Failed to survey service'); process.exit(1); } }); program.command('parley') .description('Execute a command in a pane and capture the response') .argument('<service>', 'Target service name') .argument('<command>', 'Command to execute') .option('-t, --timeout <ms>', 'Timeout in milliseconds', '3000') .action(async (service, command, options) => { try { const access = checkAccess(service); if (!access.allowed) { log.error(access.error || 'Access denied'); process.exit(1); } const output = await execInPane(service, command, parseInt(options.timeout)); console.log(output); } catch (err) { log.error(err instanceof Error ? err.message : 'Failed to parley with service'); process.exit(1); } }); program.command('whoami') .description('Show current pane identity and session info') .action(async () => { const session = getSessionInfo(); const currentService = process.env.HARBOR_SERVICE; if (!session) { log.warn('Not in a harbor session'); log.dim(' No .harbor/session.json found'); process.exit(0); } const currentServiceInfo = currentService ? session.services[currentService] : null; const canAccessList = currentServiceInfo?.canAccess || []; log.header(`${pc.cyan('⚓')} Harbor Identity`); log.plain(''); log.label('Session:', session.session); log.label('Socket:', session.socket); if (currentService && currentServiceInfo) { log.label('You are:', `${pc.green(currentService)} (window ${currentServiceInfo.window})`); if (canAccessList.length > 0) { log.label('Can access:', canAccessList.map(s => pc.cyan(s)).join(', ')); } else { log.label('Can access:', pc.dim('(none configured)')); } } else { log.label('You are:', pc.dim('external (not in a harbor pane)')); log.label('Can access:', pc.green('all services')); } log.plain(''); log.dim(' Run "harbor context" for full documentation'); log.dim(' Run "harbor bearings" to see all services'); }); program.command('context') .description('Output session context for AI agents (markdown format)') .action(async () => { const session = getSessionInfo(); const currentService = process.env.HARBOR_SERVICE; if (!session) { console.log(`# Harbor Session No active harbor session found. Run \`harbor launch\` to start one. `); process.exit(0); } const currentServiceInfo = currentService ? session.services[currentService] : null; const canAccessList = currentServiceInfo?.canAccess || []; let output = `# Harbor Session Context You are running inside a **harbor** tmux session, which orchestrates multiple development services. ## Current Session - **Session**: ${session.session} - **Socket**: ${session.socket} - **Started**: ${session.startedAt} `; if (currentService) { output += `- **Your Pane**: ${currentService} (window ${currentServiceInfo?.window}) `; } output += ` ## Available Services | Service | Window | You Can Access | |---------|--------|----------------| `; for (const [name, info] of Object.entries(session.services)) { const isCurrent = name === currentService; const hasAccess = !currentService || canAccessList.includes(name) || name === currentService; const accessIcon = isCurrent ? '(you)' : hasAccess ? '✓' : '✗'; output += `| ${name} | ${info.window} | ${accessIcon} |\n`; } output += ` ## Inter-Pane Communication Commands You can interact with other service panes using these commands: ### \`harbor hail <service> "<command>"\` Send keystrokes to another pane (fire-and-forget). \`\`\`bash harbor hail repl "echo hello" \`\`\` ### \`harbor survey <service> [--lines N]\` Capture the current output/scrollback from another pane. \`\`\`bash harbor survey web --lines 50 \`\`\` ### \`harbor parley <service> "<command>" [--timeout ms]\` Execute a command in another pane and capture the response. Uses markers to delimit output. Good for REPLs and CLIs. \`\`\`bash harbor parley repl "users" --timeout 3000 \`\`\` ## Access Control `; if (currentService) { if (canAccessList.length > 0) { output += `Your service (${currentService}) can access: **${canAccessList.join(', ')}** To access other services, add them to \`canAccess\` in harbor.json and restart the session. `; } else { output += `Your service (${currentService}) has no \`canAccess\` configured. Add services to \`canAccess\` in harbor.json to enable inter-pane communication: \`\`\`json { "name": "${currentService}", "canAccess": ["repl", "web"] } \`\`\` `; } } else { output += `You are running from outside the harbor session, so you have access to all services. `; } output += ` ## Other Useful Commands - \`harbor bearings\` - Show session status and running services - \`harbor anchor\` - Attach to the tmux session interactively - \`harbor scuttle\` - Stop all services `; console.log(output); }); program.parse(); function fileExists(path) { return fs.existsSync(`${process.cwd()}/${path}`); } export function isProjectDirectory(dirPath) { return possibleProjectFiles.some(file => { try { return fs.existsSync(path.join(process.cwd(), dirPath, file)); } catch { return false; } }); } export function validateConfig(config) { if (!Array.isArray(config.services)) { return 'Services must be an array'; } const serviceNames = new Set(config.services.map(s => s.name)); for (const service of config.services) { if (!service.name) { return 'Service name is required'; } if (!service.path) { return 'Service path is required'; } // Validate canAccess references if (service.canAccess) { for (const targetName of service.canAccess) { if (!serviceNames.has(targetName)) { return `Service "${service.name}" has canAccess reference to unknown service "${targetName}"`; } if (targetName === service.name) { return `Service "${service.name}" cannot have canAccess reference to itself`; } } } } // Validate before scripts if (config.before && !Array.isArray(config.before)) { return 'Before scripts must be an array'; } if (config.before) { for (let i = 0; i < config.before.length; i++) { const script = config.before[i]; if (!script.path) { return `Before script ${i} must have a path`; } if (!script.command) { return `Before script ${i} must have a command`; } } } // Validate after scripts if (config.after && !Array.isArray(config.after)) { return 'After scripts must be an array'; } if (config.after) { for (let i = 0; i < config.after.length; i++) { const script = config.after[i]; if (!script.path) { return `After script ${i} must have a path`; } if (!script.command) { return `After script ${i} must have a command`; } } } return null; } async function generateDevFile(dirPath) { let config; let writeToPackageJson = false; try { // First try to read from harbor.json try { const existing = await fs.promises.readFile('harbor.json', 'utf-8'); config = JSON.parse(existing); log.info('Found existing harbor.json, scanning for new services...'); } catch (err) { if (err.code !== 'ENOENT') { throw new Error(`Error reading harbor.json: ${err instanceof Error ? err.message : 'Unknown error'}`); } // If harbor.json doesn't exist, try package.json try { const packageData = await fs.promises.readFile('package.json', 'utf-8'); const packageJson = JSON.parse(packageData); if (packageJson.harbor) { // Existing harbor config in package.json, use it config = packageJson.harbor; writeToPackageJson = true; log.info('Found existing harbor config in package.json, scanning for new services...'); } else { // package.json exists but no harbor config - ask user where to store it const choice = await promptConfigLocation(); writeToPackageJson = choice === 'package.json'; config = { services: [], before: [], after: [], }; log.step(`Creating new harbor config in ${pc.cyan(choice)}...`); } } catch (err) { if (err.code !== 'ENOENT') { throw new Error(`Error reading package.json: ${err instanceof Error ? err.message : 'Unknown error'}`); } // No package.json, create harbor.json config = { services: [], }; log.step(`Creating new ${pc.cyan('harbor.json')}...`); } } // Create a map of existing services for easy lookup const existing = new Set(config.services.map(s => s.name)); let newServicesAdded = false; const folders = await fs.promises.readdir(dirPath, { withFileTypes: true }); for (const folder of folders) { if (folder.isDirectory()) { const folderPath = path.join(dirPath, folder.name); // Only add directories that contain project files and aren't already in config if (isProjectDirectory(folderPath) && !existing.has(folder.name)) { const service = { name: folder.name, path: folderPath, }; // Try to determine default command based on project type if (fs.existsSync(path.join(folderPath, 'package.json'))) { service.command = 'npm run dev'; } else if (fs.existsSync(path.join(folderPath, 'go.mod'))) { service.command = 'go run .'; } config.services.push(service); log.success(`Added service: ${pc.green(folder.name)}`); newServicesAdded = true; } else if (existing.has(folder.name)) { log.dim(` Skipping existing service: ${folder.name}`); } else { log.dim(` Skipping directory ${folder.name} (no recognized project files)`); } } } if (!newServicesAdded) { log.info('No new services found to add, feel free to add them manually'); } const validationError = validateConfig(config); if (validationError) { throw new Error(`Invalid harbor configuration: ${validationError}`); } if (writeToPackageJson) { // Update package.json const packageData = await fs.promises.readFile('package.json', 'utf-8'); const packageJson = JSON.parse(packageData); packageJson.harbor = config; await fs.promises.writeFile('package.json', JSON.stringify(packageJson, null, 2), 'utf-8'); log.success(`${pc.cyan('package.json')} updated with harbor configuration`); log.plain(''); log.info(`${pc.dim('Tip:')} To enable IntelliSense, add this to ${pc.cyan('.vscode/settings.json')}:`); log.plain(pc.dim(' {')); log.plain(pc.dim(' "json.schemas": [{')); log.plain(pc.dim(' "fileMatch": ["package.json"],')); log.plain(pc.dim(' "url": "https://raw.githubusercontent.com/Abyrd9/harbor-cli/main/harbor.package-json.schema.json"')); log.plain(pc.dim(' }]')); log.plain(pc.dim(' }')); } else { // Write to harbor.json with $schema for IntelliSense const configWithSchema = { $schema: 'https://raw.githubusercontent.com/Abyrd9/harbor-cli/main/harbor.schema.json', ...config, }; await fs.promises.writeFile('harbor.json', JSON.stringify(configWithSchema, null, 2), 'utf-8'); log.success(`${pc.cyan('harbor.json')} created successfully`); } log.plain(''); log.info(`${pc.dim('Important:')} Verify the auto-detected commands are correct for your services`); return true; } catch (err) { throw new Error(`Error processing directory: ${err instanceof Error ? err.message : 'Unknown error'}`); } } async function readHarborConfig() { // First try to read from harbor.json try { const data = await fs.promises.readFile('harbor.json', 'utf-8'); const config = JSON.parse(data); const validationError = validateConfig(config); if (validationError) { throw new Error(`Invalid configuration in harbor.json: ${validationError}`); } return config; } catch (err) { if (err.code !== 'ENOENT') { throw err; } } // If harbor.json doesn't exist, try package.json try { const packageData = await fs.promises.readFile('package.json', 'utf-8'); const packageJson = JSON.parse(packageData); if (packageJson.harbor) { const config = packageJson.harbor; const validationError = validateConfig(config); if (validationError) { throw new Error(`Invalid configuration in package.json harbor field: ${validationError}`); } return config; } } catch (err) { if (err.code !== 'ENOENT') { throw err; } } throw new Error('No harbor configuration found in harbor.json or package.json'); } async function execute(scripts, scriptType) { if (!scripts || scripts.length === 0) { return; } log.header(`Running ${scriptType} scripts...`); for (let i = 0; i < scripts.length; i++) { const script = scripts[i]; log.plain(''); log.step(`${pc.dim(`[${i + 1}/${scripts.length}]`)} ${pc.cyan(script.command)}`); log.dim(` in ${script.path}`); try { await new Promise((resolve, reject) => { const process = spawn('sh', ['-c', `cd "${script.path}" && ${script.command}`], { stdio: 'inherit', }); process.on('close', (code) => { if (code === 0) { log.success(`${scriptType} script ${i + 1} completed`); resolve(null); } else { reject(new Error(`${scriptType} script ${i + 1} exited with code ${code}`)); } }); process.on('error', (err) => { reject(new Error(`${scriptType} script ${i + 1} failed: ${err.message}`)); }); }); } catch (err) { log.error(`Error executing ${scriptType} script ${i + 1}: ${err instanceof Error ? err.message : 'Unknown error'}`); throw err; } } log.plain(''); log.success(`All ${scriptType} scripts completed`); } async function runServices(options = {}) { const hasHarborConfig = checkHasHarborConfig(); if (!hasHarborConfig) { log.error('No harbor configuration found'); log.plain(''); log.info('To initialize a new Harbor project:'); log.cmd('harbor dock'); process.exit(1); } // Load and validate config let config; try { config = await readHarborConfig(); const validationError = validateConfig(config); if (validationError) { log.error(`Invalid harbor.json configuration: ${validationError}`); process.exit(1); } } catch (err) { log.error(`Error reading config: ${err}`); process.exit(1); } ensureLogSetup(config); // Execute before scripts try { await execute(config.before || [], 'before'); } catch { log.error('Before scripts failed, aborting launch'); process.exit(1); } // Ensure scripts exist and are executable await ensureScriptsExist(); // Execute the script directly using spawn to handle I/O streams const scriptPath = path.join(getScriptsDir(), 'dev.sh'); const env = { ...process.env, HARBOR_DETACH: options.detach ? '1' : '0', HARBOR_SESSION_NAME: options.name || '', }; const command = spawn('bash', [scriptPath], { stdio: 'inherit', // This will pipe stdin/stdout/stderr to the parent process env, }); return new Promise((resolve) => { command.on('error', (err) => { log.error(`Error running dev.sh: ${err}`); process.exit(1); }); command.on('close', async (code) => { if (code !== 0) { log.error(`dev.sh exited with code ${code}`); process.exit(1); } // Only execute after scripts in attached mode // In headless mode, after scripts are run by 'scuttle' when session is killed if (!options.detach) { try { await execute(config.after || [], 'after'); } catch { log.error('After scripts failed'); process.exit(1); } } resolve(); }); }); } function ensureLogSetup(config) { const shouldLog = config.services.some((service) => service.log); if (!shouldLog) { return; } const harborDir = path.join(process.cwd(), '.harbor'); if (!fs.existsSync(harborDir)) { fs.mkdirSync(harborDir, { recursive: true }); } } // Get the package root directory function getPackageRoot() { const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); return path.join(__dirname, '..'); } // Get the template scripts directory (where our source scripts live) function getTemplateScriptsDir() { return path.join(getPackageRoot(), 'scripts'); } // Get the scripts directory (where we'll create the scripts for the user) function getScriptsDir() { return path.join(getPackageRoot(), 'scripts'); } async function ensureScriptsExist() { const scriptsDir = getScriptsDir(); const templateDir = getTemplateScriptsDir(); // Ensure scripts directory exists if (!fs.existsSync(scriptsDir)) { fs.mkdirSync(scriptsDir, { recursive: true }); } try { const scriptPath = path.join(scriptsDir, 'dev.sh'); const templatePath = path.join(templateDir, 'dev.sh'); // Create the script if it doesn't exist if (!fs.existsSync(scriptPath)) { const templateContent = readFileSync(templatePath, 'utf-8'); fs.writeFileSync(scriptPath, templateContent, 'utf-8'); console.log('Created dev.sh'); } // Make the script executable chmodSync(scriptPath, '755'); } catch (err) { console.error('Error setting up dev.sh:', err); throw err; } } function checkHasHarborConfig() { // Check for harbor.json if (fileExists('harbor.json')) { return true; } // Check for harbor config in package.json try { const packageData = fs.readFileSync(`${process.cwd()}/package.json`, 'utf-8'); const packageJson = JSON.parse(packageData); return !!packageJson.harbor; } catch { return false; } }