#!/usr/bin/env node /** * PaddedCell Plugin Installer v0.3.0 */ import { execSync } from 'child_process'; import { existsSync, mkdirSync, copyFileSync, chmodSync, readdirSync, rmSync, readFileSync, writeFileSync, } from 'fs'; import { randomBytes } from 'crypto'; 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 = 'padded-cell'; const SRC_DIST_DIR = join(__dirname, 'dist', PLUGIN_NAME); 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'), }; 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) { 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; entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); } } function detectEnvironment() { logStep(1, 6, '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 { env.goVersion = exec('go version', { silent: true }).trim(); logOk(`Go: ${env.goVersion}`); } catch { logErr('Go not found'); } try { logOk(`openclaw at ${exec('which openclaw', { silent: true }).trim()}`); } catch { logWarn('openclaw CLI not in PATH'); } return env; } function checkDeps(env) { if (options.skipCheck) { logStep(2, 6, 'Skipping dep checks'); return; } logStep(2, 6, 'Checking dependencies...'); let fail = false; if (!env.nodeVersion || parseInt(env.nodeVersion.slice(1)) < 18) { logErr('Node.js 18+ required'); fail = true; } if (!env.goVersion) { logErr('Go 1.22+ required'); fail = true; } if (fail) { log('\nInstall missing deps and retry.', 'red'); process.exit(1); } logOk('All deps OK'); } function ensureBuildSecret() { const secretFile = join(__dirname, '.build-secret'); if (existsSync(secretFile)) { const existing = readFileSync(secretFile, 'utf8').trim(); if (existing.length >= 32) { logOk('Reusing existing build secret'); return existing; } } const secret = randomBytes(32).toString('hex'); writeFileSync(secretFile, secret + '\n', { mode: 0o600 }); logOk('Generated new build secret'); return secret; } async function build() { logStep(3, 6, 'Building components...'); const buildSecret = ensureBuildSecret(); rmSync(SRC_DIST_DIR, { recursive: true, force: true }); log(' Building secret-mgr...', 'blue'); const pmDir = join(__dirname, 'secret-mgr'); exec('go mod tidy', { cwd: pmDir, silent: !options.verbose }); const ldflags = `-X main.buildSecret=${buildSecret}`; exec(`go build -ldflags "${ldflags}" -o dist/secret-mgr src/main.go`, { cwd: pmDir, silent: !options.verbose }); chmodSync(join(pmDir, 'dist', 'secret-mgr'), 0o755); logOk('secret-mgr'); log(' Building ego-mgr...', 'blue'); const emDir = join(__dirname, 'ego-mgr'); exec('go mod tidy', { cwd: emDir, silent: !options.verbose }); exec('go build -o dist/ego-mgr src/main.go', { cwd: emDir, silent: !options.verbose }); chmodSync(join(emDir, 'dist', 'ego-mgr'), 0o755); logOk('ego-mgr'); log(' Building pcguard...', 'blue'); const pgDir = join(__dirname, 'pcguard'); exec('go mod tidy', { cwd: pgDir, silent: !options.verbose }); exec('go build -o dist/pcguard src/main.go', { cwd: pgDir, silent: !options.verbose }); chmodSync(join(pgDir, 'dist', 'pcguard'), 0o755); logOk('pcguard'); log(' Building lock-mgr...', 'blue'); const lmDir = join(__dirname, 'lock-mgr'); exec('go mod tidy', { cwd: lmDir, silent: !options.verbose }); exec('go build -o dist/lock-mgr .', { cwd: lmDir, silent: !options.verbose }); chmodSync(join(lmDir, 'dist', 'lock-mgr'), 0o755); logOk('lock-mgr'); log(' Building plugin...', 'blue'); const pluginDir = join(__dirname, 'plugin'); exec('npm install', { cwd: pluginDir, silent: !options.verbose }); exec('npx tsc', { cwd: pluginDir, silent: !options.verbose }); logOk('plugin'); const skillsSrc = join(__dirname, 'skills'); const skillsDist = join(SRC_DIST_DIR, 'skills'); if (existsSync(skillsSrc)) { copyDir(skillsSrc, skillsDist); logOk('skills copied to dist/padded-cell/skills'); } } function handoffSecretIfPossible(openclawPath) { // Check both old (pass_mgr) and new (secret-mgr) binary names let passMgrPath = join(openclawPath, 'bin', 'secret-mgr'); if (!existsSync(passMgrPath)) { passMgrPath = join(openclawPath, 'bin', 'pass_mgr'); } if (!existsSync(passMgrPath)) return null; const storeA = join(openclawPath, 'pc-pass-store'); const storeB = join(openclawPath, 'pc-secret-store'); if (!existsSync(storeA) && !existsSync(storeB)) return null; const secretFile = join(openclawPath, 'pc-pass-store.secret'); try { exec(`${passMgrPath} admin handoff ${secretFile}`, { silent: !options.verbose }); logOk(`handoff secret → ${secretFile}`); return secretFile; } catch (err) { logWarn(`handoff failed: ${err.message}`); return null; } } function clearInstallTargets(openclawPath) { const binDir = join(openclawPath, 'bin'); for (const name of ['pass_mgr', 'secret-mgr', 'ego-mgr', 'pcguard', 'lock-mgr']) { const p = join(binDir, name); if (existsSync(p)) { rmSync(p, { force: true }); logOk(`Removed ${p}`); } } const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); if (existsSync(destDir)) { rmSync(destDir, { recursive: true, force: true }); logOk(`Removed ${destDir}`); } } function cleanupConfig(openclawPath) { const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); const skillsDir = join(openclawPath, 'skills'); try { const allow = getOpenclawConfig('plugins.allow', []); const idx = allow.indexOf(PLUGIN_NAME); if (idx !== -1) { allow.splice(idx, 1); setOpenclawConfig('plugins.allow', allow); logOk('Removed from allow list'); } unsetOpenclawConfig(`plugins.entries.${PLUGIN_NAME}`); logOk('Removed plugin entry'); const paths = getOpenclawConfig('plugins.load.paths', []); const pidx = paths.indexOf(destDir); if (pidx !== -1) { paths.splice(pidx, 1); setOpenclawConfig('plugins.load.paths', paths); logOk('Removed from load paths'); } const skillEntries = ['pcexec', 'safe-restart', 'safe_restart', 'pass-mgr', 'secret-mgr', 'ego-mgr']; for (const sk of skillEntries) { const p = join(skillsDir, sk); if (existsSync(p)) { rmSync(p, { recursive: true, force: true }); logOk(`Removed skill ${p}`); } } } catch (err) { logWarn(`Config cleanup: ${err.message}`); } } async function install() { if (options.buildOnly) { logStep(4, 6, 'Skipping install (--build-only)'); return null; } logStep(4, 6, 'Installing...'); const openclawPath = resolveOpenclawPath(); const binDir = join(openclawPath, 'bin'); const pluginsDir = join(openclawPath, 'plugins'); const destDir = join(pluginsDir, PLUGIN_NAME); const skillsDir = join(openclawPath, 'skills'); const distSkillsDir = join(SRC_DIST_DIR, 'skills'); log(` OpenClaw path: ${openclawPath}`, 'blue'); // update/reinstall path: remove old install first if (existsSync(destDir) || existsSync(join(binDir, 'pass_mgr')) || existsSync(join(binDir, 'secret-mgr')) || existsSync(join(binDir, 'pcguard'))) { logWarn('Existing install detected, uninstalling before install...'); handoffSecretIfPossible(openclawPath); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); } if (existsSync(destDir)) rmSync(destDir, { recursive: true, force: true }); copyDir(SRC_DIST_DIR, destDir); copyFileSync(join(__dirname, 'plugin', 'openclaw.plugin.json'), join(destDir, 'openclaw.plugin.json')); copyFileSync(join(__dirname, 'plugin', 'package.json'), join(destDir, 'package.json')); logOk(`Plugin files → ${destDir}`); exec('npm install --omit=dev', { cwd: destDir, silent: !options.verbose }); logOk('Runtime deps installed'); mkdirSync(binDir, { recursive: true }); const bins = [ { name: 'secret-mgr', src: join(__dirname, 'secret-mgr', 'dist', 'secret-mgr') }, { name: 'ego-mgr', src: join(__dirname, 'ego-mgr', 'dist', 'ego-mgr') }, { name: 'pcguard', src: join(__dirname, 'pcguard', 'dist', 'pcguard') }, { name: 'lock-mgr', src: join(__dirname, 'lock-mgr', 'dist', 'lock-mgr') }, ]; for (const b of bins) { const dest = join(binDir, b.name); copyFileSync(b.src, dest); chmodSync(dest, 0o755); logOk(`${b.name} → ${dest}`); } // Only copy dist/padded-cell/skills to ~/.openclaw/skills mkdirSync(skillsDir, { recursive: true }); if (existsSync(distSkillsDir)) { for (const entry of readdirSync(distSkillsDir, { withFileTypes: true })) { const s = join(distSkillsDir, entry.name); const d = join(skillsDir, entry.name); rmSync(d, { recursive: true, force: true }); entry.isDirectory() ? copyDir(s, d) : copyFileSync(s, d); logOk(`skill synced → ${d}`); } } // Initialize ego.json if it doesn't exist const egoJsonPath = join(openclawPath, 'ego.json'); if (!existsSync(egoJsonPath)) { const defaultEgo = { columns: ['default-username', 'name', 'discord-id', 'email', 'role', 'position', 'date-of-birth', 'agent-id', 'gender'], 'public-columns': ['git-host', 'keycloak-host'], 'public-scope': {}, 'agent-scope': {}, }; writeFileSync(egoJsonPath, JSON.stringify(defaultEgo, null, 2) + '\n', { mode: 0o644 }); logOk('Created ego.json'); } else { logOk('ego.json already exists'); } // if prior encrypted store exists, run init-from once new binary is installed const hasStore = existsSync(join(openclawPath, 'pc-pass-store')) || existsSync(join(openclawPath, 'pc-secret-store')); const secretFile = join(openclawPath, 'pc-pass-store.secret'); if (hasStore && existsSync(secretFile)) { const passMgrPath = join(binDir, 'secret-mgr'); try { exec(`${passMgrPath} admin init-from ${secretFile}`, { silent: !options.verbose }); logOk('init-from completed from handoff secret'); } catch (err) { logWarn(`init-from failed: ${err.message}`); } } return { binDir, destDir }; } async function configure() { if (options.buildOnly) { logStep(5, 6, 'Skipping config'); return; } logStep(5, 6, 'Configuring OpenClaw...'); const openclawPath = resolveOpenclawPath(); const destDir = join(openclawPath, 'plugins', PLUGIN_NAME); const secretMgrPath = join(openclawPath, 'bin', 'secret-mgr'); 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}`); const plugins = getOpenclawConfig('plugins', {}); plugins.entries = plugins.entries || {}; const existingEntry = plugins.entries[PLUGIN_NAME] || {}; const existingConfig = existingEntry.config || {}; const defaultConfig = { enabled: true, secretMgrPath, openclawProfilePath: openclawPath }; plugins.entries[PLUGIN_NAME] = { ...existingEntry, enabled: existingEntry.enabled ?? true, config: { ...defaultConfig, ...existingConfig, }, }; setOpenclawConfig('plugins', plugins); logOk('Plugin entry configured (preserved existing config, added missing defaults)'); } catch (err) { logWarn(`Config failed: ${err.message}`); } } function summary() { logStep(6, 6, 'Done!'); console.log(''); log('╔══════════════════════════════════════════════╗', 'cyan'); log('║ PaddedCell v0.3.0 Install Complete ║', 'cyan'); log('╚══════════════════════════════════════════════╝', 'cyan'); if (options.buildOnly) { log('\nBuild-only — binaries not installed.', 'yellow'); return; } console.log(''); log('Next steps:', 'blue'); log(' 1. openclaw gateway restart', 'cyan'); console.log(''); } async function uninstall() { log('Uninstalling PaddedCell...', 'cyan'); const openclawPath = resolveOpenclawPath(); handoffSecretIfPossible(openclawPath); clearInstallTargets(openclawPath); cleanupConfig(openclawPath); log('\nRun: openclaw gateway restart', 'yellow'); } async function main() { console.log(''); log('╔══════════════════════════════════════════════╗', 'cyan'); log('║ PaddedCell Plugin Installer v0.3.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(); await configure(); } summary(); } catch (err) { log(`\nInstallation failed: ${err.message}`, 'red'); process.exit(1); } } main();