#!/usr/bin/env node /** * Dialectic.OpenclawPlugin installer (modeled on Fabric's install.mjs). * * node scripts/install.mjs --install build + install + configure * node scripts/install.mjs --build-only build only * node scripts/install.mjs --uninstall remove plugin + config * * Flags: * --skip-check skip Node version check * --verbose / -v verbose build output * --openclaw-profile-path override ~/.openclaw target * --backend-url seed channels.dialectic / plugin * backendUrl config (default left unset * so the manifest's default applies) */ import { execSync } from 'child_process'; import { existsSync, mkdirSync, copyFileSync, readdirSync, rmSync } from 'fs'; import { dirname, join, resolve } from 'path'; import { fileURLToPath } from 'url'; import { homedir } from 'os'; const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PLUGIN_ID = 'dialectic'; // tsconfig.plugin.json emits here. Build artifacts NEVER live alongside // .ts sources — this avoids jiti picking a stale .js when the .ts has // been updated (the bug that silently shipped wrong code several times // during the sim e2e). See tsconfig.plugin.json comment block. const DIST_DIR = join(__dirname, 'dist', PLUGIN_ID); const args = process.argv.slice(2); const opt = { install: args.includes('--install'), buildOnly: args.includes('--build-only'), skipCheck: args.includes('--skip-check'), verbose: args.includes('--verbose') || args.includes('-v'), uninstall: args.includes('--uninstall'), }; const pIdx = args.indexOf('--openclaw-profile-path'); const profileOverride = pIdx !== -1 && args[pIdx + 1] ? resolve(args[pIdx + 1]) : null; const bIdx = args.indexOf('--backend-url'); const backendUrl = bIdx !== -1 && args[bIdx + 1] ? args[bIdx + 1] : null; function openclawPath() { if (profileOverride) return profileOverride; 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', }; const log = (m, k = 'reset') => console.log(`${c[k]}${m}${c.reset}`); const step = (n, t, m) => log(`[${n}/${t}] ${m}`, 'cyan'); const ok = (m) => log(` ✓ ${m}`, 'green'); const warn = (m) => log(` ⚠ ${m}`, 'yellow'); const err = (m) => log(` ✗ ${m}`, 'red'); function exec(cmd, o = {}) { return execSync(cmd, { cwd: __dirname, stdio: o.silent ? 'pipe' : 'inherit', encoding: 'utf8', ...o, }); } function cfgGet(key, def) { try { const out = exec( `openclaw config get ${key} --json 2>/dev/null || echo undefined`, { silent: true }, ).trim(); return out === 'undefined' || out === '' ? def : JSON.parse(out); } catch { return def; } } function cfgSet(key, val) { exec(`openclaw config set ${key} '${JSON.stringify(val)}' --json`, { silent: true }); } function cfgUnset(key) { try { exec(`openclaw config unset ${key}`, { silent: true }); } catch { /* ignore */ } } function copyDir(src, dest) { mkdirSync(dest, { recursive: true }); for (const e of readdirSync(src, { withFileTypes: true })) { if (e.name === 'node_modules') continue; const s = join(src, e.name); const d = join(dest, e.name); e.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); } } function detect() { step(1, 5, 'Detecting environment...'); let node = null; try { node = exec('node --version', { silent: true }).trim(); ok(`Node ${node}`); } catch { err('Node not found'); } try { ok(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); } catch { warn('openclaw CLI not in PATH'); } return { node }; } function checkDeps(env) { if (opt.skipCheck) return; step(2, 5, 'Checking dependencies...'); if (!env.node || parseInt(env.node.slice(1), 10) < 18) { err('Node 18+ required'); process.exit(1); } ok('deps OK'); } function build() { step(3, 5, 'Building plugin...'); // Wipe dist first so the build never silently mixes new + stale files. rmSync(join(__dirname, 'dist'), { recursive: true, force: true }); exec('npm install', { silent: !opt.verbose }); exec('npm run build', { silent: !opt.verbose }); if (!existsSync(join(DIST_DIR, 'index.js'))) { throw new Error(`build produced no ${join('dist', PLUGIN_ID, 'index.js')}`); } ok(`compiled -> dist/${PLUGIN_ID}`); } function clearInstall(base) { const dest = join(base, 'plugins', PLUGIN_ID); if (existsSync(dest)) { rmSync(dest, { recursive: true, force: true }); ok(`removed ${dest}`); } } function cleanupConfig(base) { const dest = join(base, 'plugins', PLUGIN_ID); const allow = cfgGet('plugins.allow', []); if (Array.isArray(allow) && allow.includes(PLUGIN_ID)) { cfgSet('plugins.allow', allow.filter((x) => x !== PLUGIN_ID)); ok('removed from plugins.allow'); } const paths = cfgGet('plugins.load.paths', []); if (Array.isArray(paths) && paths.includes(dest)) { cfgSet('plugins.load.paths', paths.filter((x) => x !== dest)); ok('removed from plugins.load.paths'); } cfgUnset(`plugins.entries.${PLUGIN_ID}`); ok('removed plugin entry'); } function install() { step(4, 5, 'Installing...'); const base = openclawPath(); const dest = join(base, 'plugins', PLUGIN_ID); log(` OpenClaw path: ${base}`, 'blue'); if (existsSync(dest)) { warn('existing install -> replacing'); clearInstall(base); } mkdirSync(dirname(dest), { recursive: true }); // Only dist/dialectic/ ships — pure compiled JS + no .ts left behind // to confuse jiti's extension resolver. Manifest + package.json are // copied explicitly because tsc doesn't touch non-.ts files. copyDir(DIST_DIR, dest); copyFileSync( join(__dirname, 'plugin', 'openclaw.plugin.json'), join(dest, 'openclaw.plugin.json'), ); copyFileSync( join(__dirname, 'plugin', 'package.json'), join(dest, 'package.json'), ); ok(`plugin files -> ${dest}`); exec('npm install --omit=dev', { cwd: dest, silent: !opt.verbose }); ok('runtime deps installed'); return { base, dest }; } function configure(base, dest) { step(5, 5, 'Configuring OpenClaw...'); const paths = cfgGet('plugins.load.paths', []); if (Array.isArray(paths) && !paths.includes(dest)) { cfgSet('plugins.load.paths', [...paths, dest]); } ok(`plugins.load.paths includes ${dest}`); const allow = cfgGet('plugins.allow', []); if (Array.isArray(allow) && !allow.includes(PLUGIN_ID)) { cfgSet('plugins.allow', [...allow, PLUGIN_ID]); } ok(`plugins.allow includes ${PLUGIN_ID}`); if (cfgGet(`plugins.entries.${PLUGIN_ID}.enabled`, undefined) === undefined) { cfgSet(`plugins.entries.${PLUGIN_ID}.enabled`, true); } ok('plugin entry configured'); // Seed backendUrl only when explicitly requested via --backend-url. // Otherwise the manifest's default (https://dialectic-api.hangman-lab.top) // applies, and operators can override later via: // openclaw config set plugins.entries.dialectic.config.backendUrl if (backendUrl) { cfgSet(`plugins.entries.${PLUGIN_ID}.config.backendUrl`, backendUrl); ok(`backendUrl = ${backendUrl}`); } } function main() { console.log(''); log('Dialectic.OpenclawPlugin installer', 'cyan'); console.log(''); try { const env = detect(); if (opt.uninstall) { const base = openclawPath(); clearInstall(base); cleanupConfig(base); log('\nRun: openclaw gateway restart', 'yellow'); return; } checkDeps(env); build(); if (opt.buildOnly) { log('\nbuild-only — not installed.', 'yellow'); return; } const { base, dest } = install(); configure(base, dest); console.log(''); log('Install complete. Next:', 'blue'); log(' 1. Provision agent api key (one per agent):', 'cyan'); log(' AGENT_ID= AGENT_WORKSPACE= AGENT_VERIFY=… \\', 'cyan'); log(' secret-mgr set --key dialectic-agent-apikey --secret ', 'cyan'); log(' (raw key minted via the backend admin endpoint, see', 'cyan'); log(' skills/dialectic-hangman-lab/dialectic-ctrl)', 'cyan'); log(' 2. openclaw gateway restart', 'cyan'); log(' 3. (sim/test only) set DIALECTIC_PLUGIN_BYPASS_HF=1 in the', 'cyan'); log(' gateway env to skip the HF on_call coverage check.', 'cyan'); console.log(''); } catch (e) { log(`\nInstall failed: ${e.message}`, 'red'); process.exit(1); } } main();