#!/usr/bin/env node /** * HarborForge Plugin Installer v0.2.0 * * Changes from v0.1.0: * - Plugin renamed from harborforge-monitor to harbor-forge * - Sidecar server removed (telemetry served directly by plugin) * - Added --install-cli flag for building and installing the hf CLI * - skills/hf/ only installed when --install-cli is present */ import { execSync } from 'child_process'; import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync, chmodSync, } from 'fs'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { homedir, platform } from 'os'; const __filename = fileURLToPath(import.meta.url); const __dirname = resolve(dirname(__filename), '..'); const PLUGIN_NAME = 'harbor-forge'; const OLD_PLUGIN_NAME = 'harborforge-monitor'; const PLUGIN_SRC_DIR = join(__dirname, 'plugin'); const SKILLS_SRC_DIR = join(__dirname, 'skills'); const args = process.argv.slice(2); const options = { openclawProfilePath: null, buildOnly: args.includes('--build-only'), skipCheck: args.includes('--skip-check'), verbose: args.includes('--verbose') || args.includes('-v'), uninstall: args.includes('--uninstall'), installOnly: args.includes('--install'), installCli: args.includes('--install-cli'), }; const profileIdx = args.indexOf('--openclaw-profile-path'); if (profileIdx !== -1 && args[profileIdx + 1]) { options.openclawProfilePath = resolve(args[profileIdx + 1]); } function resolveOpenclawPath() { if (options.openclawProfilePath) return options.openclawProfilePath; if (process.env.OPENCLAW_PATH) return resolve(process.env.OPENCLAW_PATH); return join(homedir(), '.openclaw'); } const c = { reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; function log(msg, color = 'reset') { console.log(`${c[color]}${msg}${c.reset}`); } function logStep(n, total, msg) { log(`[${n}/${total}] ${msg}`, 'cyan'); } function logOk(msg) { log(` ✓ ${msg}`, 'green'); } function logWarn(msg) { log(` ⚠ ${msg}`, 'yellow'); } function logErr(msg) { log(` ✗ ${msg}`, 'red'); } function exec(command, opts = {}) { return execSync(command, { cwd: __dirname, stdio: opts.silent ? 'pipe' : 'inherit', encoding: 'utf8', ...opts, }); } function getOpenclawConfig(key, def = undefined) { try { const out = exec(`openclaw config get ${key} --json 2>/dev/null || echo "undefined"`, { silent: true }).trim(); if (out === 'undefined' || out === '') return def; return JSON.parse(out); } catch { return def; } } function setOpenclawConfig(key, value) { exec(`openclaw config set ${key} '${JSON.stringify(value)}' --json`, { silent: true }); } function unsetOpenclawConfig(key) { try { exec(`openclaw config unset ${key}`, { silent: true }); } catch {} } function copyDir(src, dest, { exclude = [] } = {}) { mkdirSync(dest, { recursive: true }); for (const entry of readdirSync(src, { withFileTypes: true })) { const s = join(src, entry.name); const d = join(dest, entry.name); if (entry.name === 'node_modules') continue; if (exclude.includes(entry.name)) continue; entry.isDirectory() ? copyDir(s, d, { exclude }) : copyFileSync(s, d); } } function detectEnvironment() { const totalSteps = options.installCli ? 6 : 5; logStep(1, totalSteps, 'Detecting environment...'); const env = { platform: platform(), nodeVersion: null, goVersion: null }; try { env.nodeVersion = exec('node --version', { silent: true }).trim(); logOk(`Node.js ${env.nodeVersion}`); } catch { logErr('Node.js not found'); } try { logOk(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); } catch { logWarn('openclaw CLI not in PATH'); } if (options.installCli) { try { env.goVersion = exec('go version', { silent: true }).trim(); logOk(env.goVersion); } catch { logWarn('Go not found (needed for --install-cli)'); } } return env; } function checkDeps(env) { const totalSteps = options.installCli ? 6 : 5; if (options.skipCheck) { logStep(2, totalSteps, 'Skipping dep checks'); return; } logStep(2, totalSteps, 'Checking dependencies...'); let fail = false; if (!env.nodeVersion || parseInt(env.nodeVersion.slice(1)) < 18) { logErr('Node.js 18+ required'); fail = true; } if (options.installCli && !env.goVersion) { logErr('Go is required for --install-cli'); fail = true; } if (fail) { log('\nInstall missing deps and retry.', 'red'); process.exit(1); } logOk('All deps OK'); } async function build() { const totalSteps = options.installCli ? 6 : 5; logStep(3, totalSteps, 'Building plugin...'); log(' Building TypeScript plugin...', 'blue'); exec('npm install', { cwd: PLUGIN_SRC_DIR, silent: !options.verbose }); exec('npm run build', { cwd: PLUGIN_SRC_DIR, silent: !options.verbose }); logOk('plugin compiled'); } function clearInstallTargets(openclawPath) { // Remove new plugin dir const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); if (existsSync(destDir)) { rmSync(destDir, { recursive: true, force: true }); logOk(`Removed ${destDir}`); } // Remove old plugin dir if it exists const oldDestDir = join(openclawPath, 'plugins', OLD_PLUGIN_NAME); if (existsSync(oldDestDir)) { rmSync(oldDestDir, { recursive: true, force: true }); logOk(`Removed old plugin dir ${oldDestDir}`); } } function cleanupConfig(openclawPath) { const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); try { const allow = getOpenclawConfig('plugins.allow', []); // Remove both old and new names for (const name of [PLUGIN_NAME, OLD_PLUGIN_NAME]) { const idx = allow.indexOf(name); if (idx !== -1) { allow.splice(idx, 1); logOk(`Removed ${name} from allow list`); } } setOpenclawConfig('plugins.allow', allow); unsetOpenclawConfig(`plugins.entries.${PLUGIN_NAME}`); unsetOpenclawConfig(`plugins.entries.${OLD_PLUGIN_NAME}`); logOk('Removed plugin entries'); const paths = getOpenclawConfig('plugins.load.paths', []); const oldDestDir = join(openclawPath, 'plugins', OLD_PLUGIN_NAME); let changed = false; for (const p of [destDir, oldDestDir]) { const pidx = paths.indexOf(p); if (pidx !== -1) { paths.splice(pidx, 1); changed = true; } } if (changed) { setOpenclawConfig('plugins.load.paths', paths); logOk('Removed from load paths'); } } catch (err) { logWarn(`Config cleanup: ${err.message}`); } } async function install() { const totalSteps = options.installCli ? 6 : 5; if (options.buildOnly) { logStep(4, totalSteps, 'Skipping install (--build-only)'); return null; } logStep(4, totalSteps, 'Installing...'); const openclawPath = resolveOpenclawPath(); const pluginsDir = join(openclawPath, 'plugins'); const destDir = join(pluginsDir, PLUGIN_NAME); log(` OpenClaw path: ${openclawPath}`, 'blue'); if (existsSync(destDir)) { logWarn('Existing install detected, cleaning up...'); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); } // Clean up old plugin name if present const oldDestDir = join(pluginsDir, OLD_PLUGIN_NAME); if (existsSync(oldDestDir)) { logWarn('Old plugin (harborforge-monitor) detected, removing...'); rmSync(oldDestDir, { recursive: true, force: true }); cleanupConfig(openclawPath); } // Copy compiled plugin (no server directory — sidecar removed) mkdirSync(destDir, { recursive: true }); copyDir(PLUGIN_SRC_DIR, destDir); logOk(`Plugin files → ${destDir}`); // Copy skills (exclude hf/ unless --install-cli) if (existsSync(SKILLS_SRC_DIR)) { const skillsDestDir = join(openclawPath, 'skills'); const excludeSkills = options.installCli ? [] : ['hf']; copyDir(SKILLS_SRC_DIR, skillsDestDir, { exclude: excludeSkills }); if (options.installCli) { logOk(`Skills (including hf) → ${skillsDestDir}`); } else { logOk(`Skills (hf skipped, use --install-cli) → ${skillsDestDir}`); } } // Install runtime deps exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose }); logOk('Runtime deps installed'); return { destDir }; } async function installCli() { if (!options.installCli) return; const totalSteps = 6; logStep(5, totalSteps, 'Building and installing hf CLI...'); const openclawPath = resolveOpenclawPath(); const binDir = join(openclawPath, 'bin'); mkdirSync(binDir, { recursive: true }); // Find CLI source — look for HarborForge.Cli relative to project root const projectRoot = resolve(__dirname, '..'); const cliDir = join(projectRoot, 'HarborForge.Cli'); if (!existsSync(cliDir)) { // Try parent directory (monorepo layout) const monoCliDir = resolve(projectRoot, '..', 'HarborForge.Cli'); if (!existsSync(monoCliDir)) { logErr(`Cannot find HarborForge.Cli at ${cliDir} or ${monoCliDir}`); logWarn('Skipping CLI installation'); return; } } const effectiveCliDir = existsSync(cliDir) ? cliDir : resolve(projectRoot, '..', 'HarborForge.Cli'); log(` Building hf from ${effectiveCliDir}...`, 'blue'); try { const hfBinary = join(binDir, 'hf'); exec(`go build -o ${hfBinary} ./cmd/hf`, { cwd: effectiveCliDir, silent: !options.verbose }); chmodSync(hfBinary, 0o755); logOk(`hf binary → ${hfBinary}`); } catch (err) { logErr(`Failed to build hf CLI: ${err.message}`); logWarn('CLI installation failed, plugin still installed'); } } async function configure() { const totalSteps = options.installCli ? 6 : 5; const step = options.installCli ? 6 : 5; if (options.buildOnly) { logStep(step, totalSteps, 'Skipping config'); return; } logStep(step, totalSteps, 'Configuring OpenClaw...'); const openclawPath = resolveOpenclawPath(); const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); try { const paths = getOpenclawConfig('plugins.load.paths', []); if (!paths.includes(destDir)) { paths.push(destDir); setOpenclawConfig('plugins.load.paths', paths); } logOk(`plugins.load.paths includes ${destDir}`); const allow = getOpenclawConfig('plugins.allow', []); if (!allow.includes(PLUGIN_NAME)) { allow.push(PLUGIN_NAME); setOpenclawConfig('plugins.allow', allow); } logOk(`plugins.allow includes ${PLUGIN_NAME}`); logOk('Plugin configured (remember to set apiKey in plugins.entries.harbor-forge.config)'); } catch (err) { logWarn(`Config failed: ${err.message}`); } } function summary() { const totalSteps = options.installCli ? 6 : 5; logStep(totalSteps, totalSteps, 'Done!'); console.log(''); log('╔════════════════════════════════════════════╗', 'cyan'); log('║ HarborForge v0.2.0 Install Complete ║', 'cyan'); log('╚════════════════════════════════════════════╝', 'cyan'); if (options.buildOnly) { log('\nBuild-only — plugin not installed.', 'yellow'); return; } console.log(''); log('Next steps:', 'blue'); log(' 1. Register server in HarborForge Monitor to get apiKey', 'cyan'); log(' 2. Edit ~/.openclaw/openclaw.json under plugins.entries.harbor-forge.config:', 'cyan'); log(' (prefer monitor_port; legacy monitorPort is still accepted)', 'cyan'); log(' {', 'cyan'); log(' "plugins": {', 'cyan'); log(' "entries": {', 'cyan'); log(' "harbor-forge": {', 'cyan'); log(' "enabled": true,', 'cyan'); log(' "config": {', 'cyan'); log(' "enabled": true,', 'cyan'); log(' "apiKey": "your-api-key"', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); log(' }', 'cyan'); log(' 3. openclaw gateway restart', 'cyan'); if (options.installCli) { console.log(''); log(' hf CLI installed to ~/.openclaw/bin/hf', 'green'); log(' Ensure ~/.openclaw/bin is in your PATH', 'cyan'); } console.log(''); } async function uninstall() { log('Uninstalling HarborForge...', 'cyan'); const openclawPath = resolveOpenclawPath(); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); // Remove CLI binary if present const hfBinary = join(openclawPath, 'bin', 'hf'); if (existsSync(hfBinary)) { rmSync(hfBinary, { force: true }); logOk('Removed hf CLI binary'); } log('\nRun: openclaw gateway restart', 'yellow'); } async function main() { console.log(''); log('╔════════════════════════════════════════════╗', 'cyan'); log('║ HarborForge Plugin Installer v0.2.0 ║', 'cyan'); log('╚════════════════════════════════════════════╝', 'cyan'); console.log(''); try { const env = detectEnvironment(); if (options.uninstall) { await uninstall(); process.exit(0); } checkDeps(env); await build(); if (!options.buildOnly) { await install(); if (options.installCli) { await installCli(); } await configure(); } summary(); } catch (err) { log(`\nInstallation failed: ${err.message}`, 'red'); process.exit(1); } } main();