diff --git a/bin/manyplug.js b/bin/manyplug.js index 494aeed..ad3a172 100755 --- a/bin/manyplug.js +++ b/bin/manyplug.js @@ -6,6 +6,9 @@ import { initCommand } from '../src/commands/init.js'; import { installCommand } from '../src/commands/install.js'; import { listCommand } from '../src/commands/list.js'; import { validateCommand } from '../src/commands/validate.js'; +import { syncCommand } from '../src/commands/sync.js'; +import { updateCommand } from '../src/commands/update.js'; +import { removeCommand } from '../src/commands/remove.js'; const require = createRequire(import.meta.url); const pkg = require('../package.json'); @@ -28,6 +31,14 @@ program .option('-g, --global', 'Install to global registry') .action(installCommand); +program + .command('remove ') + .alias('rm') + .description('Remove an installed plugin') + .option('-y, --yes', 'Skip confirmation prompt') + .option('--remove-deps', 'Also remove npm dependencies') + .action(removeCommand); + program .command('list') .alias('ls') @@ -35,6 +46,17 @@ program .option('-a, --all', 'Include disabled plugins') .action(listCommand); +program + .command('sync') + .description('Sync registry.json with installed plugins') + .action(syncCommand); + +program + .command('update [plugin]') + .description('Update plugins to match registry versions') + .option('-a, --all', 'Update all plugins (default if no name given)') + .action(updateCommand); + program .command('validate [path]') .description('Validate manyplug.json syntax') diff --git a/src/commands/remove.js b/src/commands/remove.js new file mode 100644 index 0000000..4237c8e --- /dev/null +++ b/src/commands/remove.js @@ -0,0 +1,100 @@ +import fs from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; + +const PLUGINS_DIR = path.resolve('src/plugins'); +const REGISTRY_PATH = path.resolve('registry.json'); + +/** + * Remove an installed plugin + * @param {string} pluginName - plugin name to remove + * @param {object} options - command options + */ +export async function removeCommand(pluginName, options) { + if (!pluginName) { + console.error(chalk.red('❌ Please specify a plugin name')); + console.log(chalk.gray(' Usage: manyplug remove ')); + process.exit(1); + } + + const pluginDir = path.join(PLUGINS_DIR, pluginName); + const manifestPath = path.join(pluginDir, 'manyplug.json'); + + // Check if plugin exists + if (!await fs.pathExists(manifestPath)) { + console.error(chalk.red(`❌ Plugin "${pluginName}" is not installed`)); + process.exit(1); + } + + // Read manifest to get info before removing + let manifest; + try { + manifest = await fs.readJson(manifestPath); + } catch (err) { + manifest = { name: pluginName, version: '?' }; + } + + // Confirm removal unless --yes flag + if (!options.yes) { + console.log(chalk.yellow(`⚠️ You are about to remove:`)); + console.log(chalk.white(` ${manifest.name} v${manifest.version}`)); + if (manifest.description) { + console.log(chalk.gray(` ${manifest.description}`)); + } + console.log(chalk.yellow('\nThis action cannot be undone.\n')); + console.log(chalk.gray('Use --yes to skip this confirmation')); + process.exit(1); + } + + console.log(chalk.blue(`Removing "${pluginName}"...`)); + + // Track dependencies to possibly remove + const deps = manifest.dependencies || {}; + const hasDeps = Object.keys(deps).length > 0; + + try { + // Remove plugin directory + await fs.remove(pluginDir); + console.log(chalk.green(`✅ Removed "${pluginName}"`)); + + // Update registry if exists + if (await fs.pathExists(REGISTRY_PATH)) { + try { + const registry = await fs.readJson(REGISTRY_PATH); + if (registry.plugins && registry.plugins[pluginName]) { + delete registry.plugins[pluginName]; + registry.lastUpdated = new Date().toISOString(); + await fs.writeJson(REGISTRY_PATH, registry, { spaces: 2 }); + console.log(chalk.gray(' Updated registry')); + } + } catch (err) { + // Ignore registry update errors + } + } + + // Warning about orphaned dependencies + if (hasDeps && !options.removeDeps) { + console.log(chalk.yellow('\n⚠️ Plugin had npm dependencies:')); + for (const [dep, version] of Object.entries(deps)) { + console.log(chalk.gray(` - ${dep}@${version}`)); + } + console.log(chalk.gray('Run with --remove-deps to also uninstall these')); + } + + // Remove npm dependencies if requested + if (hasDeps && options.removeDeps) { + console.log(chalk.blue('\nRemoving npm dependencies...')); + const depList = Object.keys(deps).join(' '); + try { + execSync(`npm uninstall ${depList}`, { cwd: process.cwd(), stdio: 'pipe' }); + console.log(chalk.green('✅ Dependencies removed')); + } catch (err) { + console.warn(chalk.yellow(`⚠️ Could not remove dependencies: ${err.message}`)); + } + } + } catch (err) { + console.error(chalk.red(`❌ Failed to remove plugin: ${err.message}`)); + process.exit(1); + } +} diff --git a/src/commands/sync.js b/src/commands/sync.js new file mode 100644 index 0000000..bfc19f2 --- /dev/null +++ b/src/commands/sync.js @@ -0,0 +1,75 @@ +import fs from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; + +const PLUGINS_DIR = path.resolve('src/plugins'); +const REGISTRY_PATH = path.resolve('registry.json'); + +/** + * Sync registry.json with installed plugins. + * Scans all plugins in src/plugins/ and updates registry.json + */ +export async function syncCommand() { + console.log(chalk.blue('Syncing registry...')); + + // Load existing registry or create new one + let registry = { lastUpdated: new Date().toISOString(), plugins: {} }; + + if (await fs.pathExists(REGISTRY_PATH)) { + try { + registry = await fs.readJson(REGISTRY_PATH); + } catch (err) { + console.warn(chalk.yellow('⚠️ Could not read existing registry, creating new one')); + } + } + + // Scan plugins directory + if (!await fs.pathExists(PLUGINS_DIR)) { + console.warn(chalk.yellow('⚠️ No plugins directory found')); + await fs.writeJson(REGISTRY_PATH, registry, { spaces: 2 }); + console.log(chalk.green(`✅ Registry updated at ${path.relative(process.cwd(), REGISTRY_PATH)}`)); + return; + } + + const entries = await fs.readdir(PLUGINS_DIR, { withFileTypes: true }); + let updated = 0; + let added = 0; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const manifestPath = path.join(PLUGINS_DIR, entry.name, 'manyplug.json'); + if (!await fs.pathExists(manifestPath)) continue; + + try { + const manifest = await fs.readJson(manifestPath); + const pluginName = manifest.name || entry.name; + + // Check if plugin exists in registry and version changed + const existing = registry.plugins[pluginName]; + if (!existing) { + added++; + } else if (existing.version !== manifest.version) { + updated++; + } + + registry.plugins[pluginName] = { + ...manifest, + _syncedAt: new Date().toISOString(), + _sourcePath: path.join('src/plugins', entry.name) + }; + } catch (err) { + console.warn(chalk.yellow(`⚠️ Failed to read ${entry.name}: ${err.message}`)); + } + } + + // Update timestamp + registry.lastUpdated = new Date().toISOString(); + + await fs.writeJson(REGISTRY_PATH, registry, { spaces: 2 }); + + console.log(chalk.green(`✅ Registry synced`)); + console.log(chalk.gray(` Added: ${added}`)); + console.log(chalk.gray(` Updated: ${updated}`)); + console.log(chalk.gray(` Total plugins: ${Object.keys(registry.plugins).length}`)); +} diff --git a/src/commands/update.js b/src/commands/update.js new file mode 100644 index 0000000..93cdfd9 --- /dev/null +++ b/src/commands/update.js @@ -0,0 +1,158 @@ +import fs from 'fs-extra'; +import path from 'path'; +import chalk from 'chalk'; +import { execSync } from 'child_process'; + +const PLUGINS_DIR = path.resolve('src/plugins'); +const REGISTRY_PATH = path.resolve('registry.json'); + +/** + * Check and update plugins to match registry versions + * @param {string} pluginName - specific plugin to update, or undefined for all + * @param {object} options - command options + */ +export async function updateCommand(pluginName, options) { + // Check if registry exists + if (!await fs.pathExists(REGISTRY_PATH)) { + console.error(chalk.red('❌ registry.json not found. Run "manyplug sync" first.')); + process.exit(1); + } + + const registry = await fs.readJson(REGISTRY_PATH); + + if (pluginName) { + // Update specific plugin + await updatePlugin(pluginName, registry.plugins[pluginName]); + } else { + // Update all plugins + const plugins = Object.keys(registry.plugins); + if (plugins.length === 0) { + console.log(chalk.gray('No plugins in registry')); + return; + } + + console.log(chalk.blue(`Checking ${plugins.length} plugin(s)...\n`)); + + let updated = 0; + let upToDate = 0; + let notInstalled = 0; + let errors = 0; + + for (const name of plugins) { + const result = await updatePlugin(name, registry.plugins[name], { silent: true }); + if (result === 'updated') updated++; + else if (result === 'upToDate') upToDate++; + else if (result === 'notInstalled') notInstalled++; + else errors++; + } + + console.log(chalk.bold('\nUpdate Summary:')); + console.log(chalk.green(` Updated: ${updated}`)); + console.log(chalk.gray(` Up to date: ${upToDate}`)); + if (notInstalled > 0) console.log(chalk.yellow(` Not installed: ${notInstalled}`)); + if (errors > 0) console.log(chalk.red(` Errors: ${errors}`)); + } +} + +async function updatePlugin(name, registryEntry, { silent = false } = {}) { + if (!registryEntry) { + if (!silent) console.error(chalk.red(`❌ "${name}" not found in registry`)); + return 'error'; + } + + const pluginDir = path.join(PLUGINS_DIR, name); + const manifestPath = path.join(pluginDir, 'manyplug.json'); + + // Check if plugin is installed + if (!await fs.pathExists(manifestPath)) { + // Plugin exists in registry but not installed - try to install it + if (registryEntry._sourcePath) { + if (!silent) console.log(chalk.blue(`Installing "${name}" from registry...`)); + await installFromSource(name, registryEntry); + return 'updated'; + } + if (!silent) console.log(chalk.yellow(`⚠️ "${name}" not installed (run "manyplug install ${name}")`)); + return 'notInstalled'; + } + + // Read installed manifest + const installed = await fs.readJson(manifestPath); + + // Compare versions + if (installed.version === registryEntry.version) { + if (!silent) console.log(chalk.gray(` ${name}: ${installed.version} (up to date)`)); + return 'upToDate'; + } + + // Version differs - update + if (!silent) console.log(chalk.blue(`Updating "${name}"...`)); + console.log(chalk.gray(` ${installed.version} → ${registryEntry.version}`)); + + // If we have a source path in registry, re-copy from there + if (registryEntry._sourcePath && await fs.pathExists(registryEntry._sourcePath)) { + // Backup current + const backupDir = `${pluginDir}.backup-${Date.now()}`; + await fs.copy(pluginDir, backupDir); + + try { + // Remove old version + await fs.remove(pluginDir); + + // Copy new version + await fs.copy(registryEntry._sourcePath, pluginDir); + + // Install dependencies + if (registryEntry.dependencies && Object.keys(registryEntry.dependencies).length > 0) { + await installNpmDeps(registryEntry.dependencies); + } + + if (!silent) console.log(chalk.green(` ✅ Updated to ${registryEntry.version}`)); + return 'updated'; + } catch (err) { + // Restore backup on error + console.error(chalk.red(` ❌ Update failed: ${err.message}`)); + console.log(chalk.yellow(' Restoring backup...')); + await fs.copy(backupDir, pluginDir); + await fs.remove(backupDir); + return 'error'; + } + } + + // No source path - just update the version in manifest (metadata update) + await fs.writeJson(manifestPath, { ...installed, version: registryEntry.version }, { spaces: 2 }); + if (!silent) console.log(chalk.yellow(` ⚠️ Updated version only (no source available)`)); + return 'updated'; +} + +async function installFromSource(name, registryEntry) { + const sourcePath = registryEntry._sourcePath; + const targetDir = path.join(PLUGINS_DIR, name); + + if (!await fs.pathExists(sourcePath)) { + console.error(chalk.red(` ❌ Source not found: ${sourcePath}`)); + return; + } + + await fs.ensureDir(PLUGINS_DIR); + await fs.copy(sourcePath, targetDir); + + if (registryEntry.dependencies && Object.keys(registryEntry.dependencies).length > 0) { + await installNpmDeps(registryEntry.dependencies); + } + + console.log(chalk.green(` ✅ Installed "${name}" v${registryEntry.version}`)); +} + +async function installNpmDeps(dependencies) { + const deps = Object.entries(dependencies) + .map(([name, version]) => `${name}@${version === '*' ? 'latest' : version}`) + .join(' '); + + if (!deps) return; + + try { + execSync(`npm install ${deps}`, { cwd: process.cwd(), stdio: 'pipe' }); + } catch (err) { + console.warn(chalk.yellow(` ⚠️ Failed to install dependencies: ${err.message}`)); + } +}