Files
PaddedCell/install.mjs
zhi 21d04b17cd feat: add --uninstall support to install script
- Add --uninstall flag to remove installed components
- List items to be removed before confirmation
- Remove pass_mgr binary, skills modules, and env config
- Preserve admin key directory (warns user to remove manually)
- Update PROJECT_PLAN.md with uninstall usage
2026-03-05 10:13:29 +00:00

716 lines
21 KiB
JavaScript
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* PaddedCell Plugin Installer
*
* Usage:
* node install.mjs
* node install.mjs --prefix /usr/local
* node install.mjs --build-only
* node install.mjs --skip-check
* node install.mjs --uninstall
* node install.mjs --uninstall --prefix /usr/local
*/
import { execSync, spawn } from 'child_process';
import { existsSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, chmodSync } from 'fs';
import { dirname, join, resolve } from 'path';
import { fileURLToPath } from 'url';
import { homedir, platform } from 'os';
import readline from 'readline';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Parse arguments
const args = process.argv.slice(2);
const options = {
prefix: null,
buildOnly: args.includes('--build-only'),
skipCheck: args.includes('--skip-check'),
verbose: args.includes('--verbose') || args.includes('-v'),
uninstall: args.includes('--uninstall'),
};
// Parse --prefix value
const prefixIndex = args.indexOf('--prefix');
if (prefixIndex !== -1 && args[prefixIndex + 1]) {
options.prefix = resolve(args[prefixIndex + 1]);
}
// Colors for output
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
};
function log(message, color = 'reset') {
console.log(`${colors[color]}${message}${colors.reset}`);
}
function logStep(step, message) {
log(`[${step}/7] ${message}`, 'cyan');
}
function logSuccess(message) {
log(`${message}`, 'green');
}
function logWarning(message) {
log(`${message}`, 'yellow');
}
function logError(message) {
log(`${message}`, 'red');
}
function exec(command, options = {}) {
const defaultOptions = {
cwd: __dirname,
stdio: options.silent ? 'pipe' : 'inherit',
encoding: 'utf8',
};
return execSync(command, { ...defaultOptions, ...options });
}
async function prompt(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
async function promptPassword(question) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
// Hide password input
const stdin = process.stdin;
stdin.on('data', (char) => {
char = char.toString();
switch (char) {
case '\n':
case '\r':
case '\u0004':
stdin.pause();
break;
default:
process.stdout.write('*');
break;
}
});
rl.question(question, (answer) => {
rl.close();
console.log(); // New line after password
resolve(answer.trim());
});
});
}
// ============================================================================
// Step 1: Environment Detection
// ============================================================================
function detectEnvironment() {
logStep(1, 'Detecting environment...');
const env = {
platform: platform(),
isLinux: platform() === 'linux',
isMacOS: platform() === 'darwin',
nodeVersion: null,
goVersion: null,
openclawPath: null,
openclawDir: null,
};
// Check Node.js
try {
const version = exec('node --version', { silent: true }).trim();
env.nodeVersion = version;
logSuccess(`Node.js ${version}`);
} catch {
logError('Node.js not found');
}
// Check Go
try {
const version = exec('go version', { silent: true }).trim();
env.goVersion = version;
logSuccess(`Go ${version}`);
} catch {
logError('Go not found');
}
// Check openclaw
try {
const path = exec('which openclaw', { silent: true }).trim();
env.openclawPath = path;
logSuccess(`openclaw at ${path}`);
// Try to find openclaw config dir
const home = homedir();
const possibleDirs = [
join(home, '.openclaw'),
join(home, '.config', 'openclaw'),
'/etc/openclaw',
];
for (const dir of possibleDirs) {
if (existsSync(dir)) {
env.openclawDir = dir;
logSuccess(`openclaw config dir: ${dir}`);
break;
}
}
if (!env.openclawDir) {
env.openclawDir = join(home, '.openclaw');
logWarning(`openclaw config dir not found, will use: ${env.openclawDir}`);
}
} catch {
logWarning('openclaw CLI not found in PATH');
env.openclawDir = join(homedir(), '.openclaw');
}
return env;
}
function checkDependencies(env) {
if (options.skipCheck) {
logWarning('Skipping dependency checks');
return true;
}
logStep(2, 'Checking dependencies...');
let hasErrors = false;
if (!env.nodeVersion) {
logError('Node.js is required. Please install Node.js 18+');
hasErrors = true;
} else {
const majorVersion = parseInt(env.nodeVersion.slice(1).split('.')[0]);
if (majorVersion < 18) {
logError(`Node.js 18+ required, found ${env.nodeVersion}`);
hasErrors = true;
} else {
logSuccess(`Node.js version OK`);
}
}
if (!env.goVersion) {
logError('Go is required. Please install Go 1.22+');
hasErrors = true;
} else {
logSuccess(`Go version OK`);
}
if (hasErrors) {
log('\nPlease install missing dependencies and try again.', 'red');
process.exit(1);
}
return true;
}
// ============================================================================
// Step 3: Build Components
// ============================================================================
async function buildComponents(env) {
logStep(3, 'Building components...');
// Build pass_mgr
log(' Building pass_mgr (Go)...', 'blue');
try {
const passMgrDir = join(__dirname, 'pass_mgr');
if (!existsSync(passMgrDir)) {
throw new Error('pass_mgr directory not found');
}
exec('go mod tidy', { cwd: passMgrDir, silent: !options.verbose });
exec('go build -o dist/pass_mgr src/main.go', {
cwd: passMgrDir,
silent: !options.verbose
});
// Verify binary exists
const binaryPath = join(passMgrDir, 'dist', 'pass_mgr');
if (!existsSync(binaryPath)) {
throw new Error('pass_mgr binary not found after build');
}
chmodSync(binaryPath, 0o755);
logSuccess('pass_mgr built successfully');
} catch (err) {
logError(`Failed to build pass_mgr: ${err.message}`);
throw err;
}
// Build pcexec
log(' Building pcexec (TypeScript)...', 'blue');
try {
const pcexecDir = join(__dirname, 'pcexec');
if (!existsSync(pcexecDir)) {
throw new Error('pcexec directory not found');
}
exec('npm install', { cwd: pcexecDir, silent: !options.verbose });
exec('npm run build', { cwd: pcexecDir, silent: !options.verbose });
logSuccess('pcexec built successfully');
} catch (err) {
logError(`Failed to build pcexec: ${err.message}`);
throw err;
}
// Build safe-restart
log(' Building safe-restart (TypeScript)...', 'blue');
try {
const safeRestartDir = join(__dirname, 'safe-restart');
if (!existsSync(safeRestartDir)) {
throw new Error('safe-restart directory not found');
}
exec('npm install', { cwd: safeRestartDir, silent: !options.verbose });
exec('npm run build', { cwd: safeRestartDir, silent: !options.verbose });
logSuccess('safe-restart built successfully');
} catch (err) {
logError(`Failed to build safe-restart: ${err.message}`);
throw err;
}
}
// ============================================================================
// Step 4: Install Components
// ============================================================================
async function installComponents(env) {
if (options.buildOnly) {
logStep(4, 'Skipping installation (--build-only)');
return;
}
logStep(4, 'Installing components...');
// Determine install paths
const installDir = options.prefix || env.openclawDir;
const binDir = options.prefix ? join(installDir, 'bin') : join(installDir, 'bin');
const skillsDir = join(installDir, 'skills', 'paddedcell');
log(` Install directory: ${installDir}`, 'blue');
log(` Binary directory: ${binDir}`, 'blue');
log(` Skills directory: ${skillsDir}`, 'blue');
// Create directories
mkdirSync(binDir, { recursive: true });
mkdirSync(skillsDir, { recursive: true });
// Install pass_mgr binary
log(' Installing pass_mgr binary...', 'blue');
const passMgrSource = join(__dirname, 'pass_mgr', 'dist', 'pass_mgr');
const passMgrDest = join(binDir, 'pass_mgr');
copyFileSync(passMgrSource, passMgrDest);
chmodSync(passMgrDest, 0o755);
logSuccess(`pass_mgr installed to ${passMgrDest}`);
// Install pcexec
log(' Installing pcexec module...', 'blue');
const pcexecSource = join(__dirname, 'pcexec');
const pcexecDest = join(skillsDir, 'pcexec');
copyModule(pcexecSource, pcexecDest);
logSuccess(`pcexec installed to ${pcexecDest}`);
// Install safe-restart
log(' Installing safe-restart module...', 'blue');
const safeRestartSource = join(__dirname, 'safe-restart');
const safeRestartDest = join(skillsDir, 'safe-restart');
copyModule(safeRestartSource, safeRestartDest);
logSuccess(`safe-restart installed to ${safeRestartDest}`);
// Create skill manifest
log(' Creating skill manifest...', 'blue');
const manifest = {
name: 'paddedcell',
version: '0.1.0',
description: 'Secure password management, safe execution, and coordinated restart',
tools: [
{
name: 'pcexec',
entry: './pcexec/dist/index.js',
description: 'Safe exec with password sanitization',
},
{
name: 'safe_restart',
entry: './safe-restart/dist/index.js',
description: 'Safe coordinated restart',
},
],
binaries: ['pass_mgr'],
installedAt: new Date().toISOString(),
};
writeFileSync(
join(skillsDir, 'manifest.json'),
JSON.stringify(manifest, null, 2)
);
logSuccess('Skill manifest created');
// Add to PATH if needed
if (options.prefix) {
log('\n To use pass_mgr, add the following to your shell profile:', 'yellow');
log(` export PATH="${binDir}:$PATH"`, 'cyan');
}
}
function copyModule(source, dest) {
mkdirSync(dest, { recursive: true });
const items = ['package.json', 'tsconfig.json', 'dist', 'src'];
for (const item of items) {
const srcPath = join(source, item);
const destPath = join(dest, item);
if (!existsSync(srcPath)) continue;
const stat = execSync(`stat -c %F "${srcPath}" 2>/dev/null || echo "file"`, {
silent: true,
cwd: __dirname
}).trim();
if (stat === 'directory') {
exec(`cp -r "${srcPath}" "${destPath}"`, { silent: true });
} else {
copyFileSync(srcPath, destPath);
}
}
}
// ============================================================================
// Step 5: Configuration
// ============================================================================
async function configure(env) {
if (options.buildOnly) {
logStep(5, 'Skipping configuration (--build-only)');
return;
}
logStep(5, 'Configuration...');
const passMgrPath = options.prefix
? join(options.prefix, 'bin', 'pass_mgr')
: join(env.openclawDir, 'bin', 'pass_mgr');
// Check if already initialized
const adminKeyDir = join(homedir(), '.pass_mgr');
const configPath = join(adminKeyDir, 'config.json');
if (existsSync(configPath)) {
logWarning('pass_mgr already initialized');
const reinit = await prompt(' Reinitialize? (y/N): ');
if (reinit.toLowerCase() !== 'y') {
log(' Skipping initialization');
return;
}
}
// Initialize pass_mgr
log(' Initializing pass_mgr...', 'blue');
log(' Please set your admin password for pass_mgr.', 'yellow');
const adminPassword = await promptPassword(' Admin password: ');
if (!adminPassword) {
logWarning('Empty password, skipping initialization');
return;
}
const confirmPassword = await promptPassword(' Confirm password: ');
if (adminPassword !== confirmPassword) {
logError('Passwords do not match');
return;
}
try {
// Write password to temp file and init
const tempKeyFile = join(adminKeyDir, '.tmp_key');
mkdirSync(adminKeyDir, { recursive: true, mode: 0o700 });
writeFileSync(tempKeyFile, adminPassword, { mode: 0o600 });
exec(`${passMgrPath} admin init --key-path "${tempKeyFile}"`, { silent: !options.verbose });
// Remove temp file
execSync(`rm -f "${tempKeyFile}"`);
logSuccess('pass_mgr initialized successfully');
} catch (err) {
logError(`Failed to initialize pass_mgr: ${err.message}`);
throw err;
}
// Create environment config
log(' Creating environment configuration...', 'blue');
const envConfig = [
'# PaddedCell Environment Configuration',
`export PATH="${dirname(passMgrPath)}:$PATH"`,
'export PASS_MGR_PATH="pass_mgr"',
'',
'# PaddedCell skills',
`export PADDEDCELL_SKILLS_DIR="${join(env.openclawDir || homedir(), '.openclaw', 'skills', 'paddedcell')}"`,
].join('\n');
const envFile = join(env.openclawDir || homedir(), '.openclaw', 'paddedcell.env');
writeFileSync(envFile, envConfig);
logSuccess(`Environment config written to ${envFile}`);
log('\n Add the following to your shell profile:', 'yellow');
log(` source ${envFile}`, 'cyan');
}
// ============================================================================
// Step 6: Verify Installation
// ============================================================================
async function verifyInstallation(env) {
if (options.buildOnly) {
logStep(6, 'Skipping verification (--build-only)');
return true;
}
logStep(6, 'Verifying installation...');
let allOk = true;
// Check pass_mgr
try {
const passMgrPath = options.prefix
? join(options.prefix, 'bin', 'pass_mgr')
: join(env.openclawDir, 'bin', 'pass_mgr');
if (existsSync(passMgrPath)) {
execSync(`"${passMgrPath}" --help`, { silent: true });
logSuccess('pass_mgr is working');
} else {
logError('pass_mgr binary not found');
allOk = false;
}
} catch {
logError('pass_mgr test failed');
allOk = false;
}
// Check pcexec
const pcexecPath = join(
options.prefix || env.openclawDir,
'skills', 'paddedcell', 'pcexec', 'dist', 'index.js'
);
if (existsSync(pcexecPath)) {
logSuccess('pcexec module installed');
} else {
logError('pcexec module not found');
allOk = false;
}
// Check safe-restart
const safeRestartPath = join(
options.prefix || env.openclawDir,
'skills', 'paddedcell', 'safe-restart', 'dist', 'index.js'
);
if (existsSync(safeRestartPath)) {
logSuccess('safe-restart module installed');
} else {
logError('safe-restart module not found');
allOk = false;
}
return allOk;
}
// ============================================================================
// Step 7: Print Summary
// ============================================================================
function printSummary(env) {
logStep(7, 'Installation Summary');
const installDir = options.prefix || env.openclawDir;
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Installation Complete ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
console.log('');
if (options.buildOnly) {
log('Build-only mode - binaries built but not installed', 'yellow');
console.log('');
log('Built artifacts:', 'blue');
log(` • pass_mgr: ${join(__dirname, 'pass_mgr', 'dist', 'pass_mgr')}`, 'reset');
log(` • pcexec: ${join(__dirname, 'pcexec', 'dist')}`, 'reset');
log(` • safe-restart: ${join(__dirname, 'safe-restart', 'dist')}`, 'reset');
} else {
log('Installed components:', 'blue');
log(` • pass_mgr binary: ${join(installDir, 'bin', 'pass_mgr')}`, 'reset');
log(` • pcexec module: ${join(installDir, 'skills', 'paddedcell', 'pcexec')}`, 'reset');
log(` • safe-restart module: ${join(installDir, 'skills', 'paddedcell', 'safe-restart')}`, 'reset');
console.log('');
log('Next steps:', 'blue');
console.log('');
log('1. Add to your shell profile (~/.bashrc, ~/.zshrc):', 'yellow');
log(` source ${join(installDir, 'paddedcell.env')}`, 'cyan');
console.log('');
log('2. Reload your shell or run:', 'yellow');
log(` source ${join(installDir, 'paddedcell.env')}`, 'cyan');
console.log('');
log('3. Test pass_mgr:', 'yellow');
log(' pass_mgr admin init # Initialize (if not done)', 'cyan');
log(' pass_mgr set test_key mypass # Set a test password', 'cyan');
log(' pass_mgr get test_key # Retrieve password', 'cyan');
}
console.log('');
log('For more information:', 'blue');
log(' • Documentation: https://git.hangman-lab.top/nav/PaddedCell', 'cyan');
log(' • Issues: https://git.hangman-lab.top/nav/PaddedCell/issues', 'cyan');
console.log('');
}
// ============================================================================
// Uninstall Function
// ============================================================================
async function uninstall(env) {
logStep(1, 'Uninstalling PaddedCell...');
const installDir = options.prefix || env.openclawDir;
const binDir = join(installDir, 'bin');
const skillsDir = join(installDir, 'skills', 'paddedcell');
const envFile = join(installDir, 'paddedcell.env');
const itemsToRemove = [];
// Check what exists
const passMgrBinary = join(binDir, 'pass_mgr');
if (existsSync(passMgrBinary)) {
itemsToRemove.push(passMgrBinary);
}
if (existsSync(skillsDir)) {
itemsToRemove.push(skillsDir);
}
if (existsSync(envFile)) {
itemsToRemove.push(envFile);
}
if (itemsToRemove.length === 0) {
log('No installed components found.', 'yellow');
return;
}
log('The following items will be removed:', 'yellow');
for (const item of itemsToRemove) {
log(`${item}`, 'reset');
}
// Ask for confirmation
const confirm = await prompt('\nAre you sure you want to uninstall? (y/N): ');
if (confirm.toLowerCase() !== 'y') {
log('Uninstall cancelled.', 'yellow');
return;
}
// Perform uninstall
for (const item of itemsToRemove) {
try {
if (item === passMgrBinary || item === envFile) {
execSync(`rm -f "${item}"`, { silent: true });
logSuccess(`Removed: ${item}`);
} else {
execSync(`rm -rf "${item}"`, { silent: true });
logSuccess(`Removed: ${item}`);
}
} catch (err) {
logError(`Failed to remove: ${item}`);
}
}
// Check for admin key directory
const adminKeyDir = join(homedir(), '.pass_mgr');
if (existsSync(adminKeyDir)) {
log('\n⚠ Admin key directory found:', 'yellow');
log(` ${adminKeyDir}`, 'cyan');
log(' This contains your encryption keys. Remove manually if desired.', 'yellow');
}
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Uninstall Complete ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
console.log('');
log('Remember to remove the following from your shell profile:', 'yellow');
log(` source ${envFile}`, 'cyan');
console.log('');
}
async function main() {
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'cyan');
log('║ PaddedCell Plugin Installer v0.1.0 ║', 'cyan');
log('╚════════════════════════════════════════════════════════╝', 'cyan');
console.log('');
try {
const env = detectEnvironment();
// Handle uninstall
if (options.uninstall) {
await uninstall(env);
process.exit(0);
}
checkDependencies(env);
await buildComponents(env);
await installComponents(env);
await configure(env);
const verified = await verifyInstallation(env);
if (!verified && !options.buildOnly) {
log('\nInstallation completed with warnings.', 'yellow');
}
printSummary(env);
process.exit(0);
} catch (err) {
console.log('');
log('╔════════════════════════════════════════════════════════╗', 'red');
log('║ Installation Failed ║', 'red');
log('╚════════════════════════════════════════════════════════╝', 'red');
console.log('');
log(`Error: ${err.message}`, 'red');
if (options.verbose) {
console.log(err.stack);
}
process.exit(1);
}
}
main();