#!/usr/bin/env node /** * camofox plugin manager -- install, remove, and list plugins. * * Usage: * node scripts/plugin.js install Install a plugin from git URL or local path * node scripts/plugin.js remove Remove a plugin and its config entry * node scripts/plugin.js list List installed plugins and their source * * Sources: * git:github.com/user/repo Git shorthand * https://github.com/user/repo Git URL * /absolute/path/to/plugin-dir Local directory (copied) * ./relative/path/to/plugin-dir Local directory (copied) * * Plugin name is inferred from the repo/directory name. If the repo root has * an index.js with register(), it's used directly. If it has a plugins/ subdir, * each subdirectory is installed as a separate plugin. * * After install, the plugin is added to camofox.config.json plugins[] and * npm dependencies are installed if the plugin has a package.json. */ import fs from 'fs'; import path from 'path'; import { execSync } from './exec.js'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.join(__dirname, '..'); const PLUGINS_DIR = path.join(ROOT, 'plugins'); const CONFIG_PATH = path.join(ROOT, 'camofox.config.json'); // -- Config helpers ---------------------------------------------------------- function readConfig() { try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return { id: 'camofox-browser', name: 'Camofox Browser', version: '0.0.0', plugins: [] }; } } function writeConfig(config) { fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n'); } /** * Get the set of enabled plugin names from config. * Handles both array format ["youtube"] and object format { "youtube": { "enabled": true } }. */ function getEnabledPlugins(config) { if (!config.plugins) return new Set(); if (Array.isArray(config.plugins)) return new Set(config.plugins); if (typeof config.plugins === 'object') { const enabled = new Set(); for (const [name, conf] of Object.entries(config.plugins)) { if (conf === false || (typeof conf === 'object' && conf.enabled === false)) continue; enabled.add(name); } return enabled; } return new Set(); } function addToConfig(name) { const config = readConfig(); if (Array.isArray(config.plugins)) { if (!config.plugins.includes(name)) { config.plugins.push(name); writeConfig(config); } } else if (typeof config.plugins === 'object') { if (!config.plugins[name] || config.plugins[name].enabled === false) { config.plugins[name] = config.plugins[name] || {}; config.plugins[name].enabled = true; writeConfig(config); } } else { config.plugins = [name]; writeConfig(config); } } function removeFromConfig(name) { const config = readConfig(); if (Array.isArray(config.plugins)) { config.plugins = config.plugins.filter(p => p !== name); writeConfig(config); } else if (typeof config.plugins === 'object' && config.plugins[name] !== undefined) { delete config.plugins[name]; writeConfig(config); } } // -- Source parsing ---------------------------------------------------------- function parseSource(source) { // Local path if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) { const resolved = path.resolve(source); if (!fs.existsSync(resolved)) { fatal(`Local path not found: ${resolved}`); } if (!fs.statSync(resolved).isDirectory()) { fatal(`Source must be a directory: ${resolved}`); } return { type: 'local', path: resolved, name: path.basename(resolved) }; } // Git URL -- https://, ssh://, git@, git: let gitUrl = source; if (gitUrl.startsWith('git:')) { gitUrl = gitUrl.slice(4); // git:github.com/user/repo -> https://github.com/user/repo if (!gitUrl.startsWith('http') && !gitUrl.startsWith('ssh://') && !gitUrl.startsWith('git@')) { gitUrl = `https://${gitUrl}`; } } // Strip trailing .git gitUrl = gitUrl.replace(/\.git$/, ''); // Extract name from URL const name = gitUrl.split('/').pop().replace(/[^a-zA-Z0-9_-]/g, ''); if (!name) fatal(`Cannot infer plugin name from: ${source}`); // Re-add .git for clone const cloneUrl = gitUrl.endsWith('.git') ? gitUrl : `${gitUrl}.git`; return { type: 'git', url: cloneUrl, name }; } // -- Install ----------------------------------------------------------------- function isPluginDir(dir) { const indexPath = path.join(dir, 'index.js'); if (!fs.existsSync(indexPath)) return false; const content = fs.readFileSync(indexPath, 'utf-8'); return /\bregister\b/.test(content); } function installFromLocal(srcDir, name) { const destDir = path.join(PLUGINS_DIR, name); if (fs.existsSync(destDir)) { fatal(`Plugin "${name}" already exists. Remove it first: node scripts/plugin.js remove ${name}`); } copyDirSync(srcDir, destDir); return [name]; } function installFromGit(url, name) { const tmpDir = path.join(ROOT, '.tmp-plugin-clone'); try { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true }); console.log(`Cloning ${url}...`); execSync(`git clone --depth 1 ${url} ${tmpDir}`, { stdio: 'pipe' }); // Case 1: Root is a plugin (has index.js with register) if (isPluginDir(tmpDir)) { return installFromLocal(tmpDir, name); } // Case 2: Has plugins/ subdir with plugin directories const pluginsSubdir = path.join(tmpDir, 'plugins'); if (fs.existsSync(pluginsSubdir) && fs.statSync(pluginsSubdir).isDirectory()) { const installed = []; for (const entry of fs.readdirSync(pluginsSubdir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; const subDir = path.join(pluginsSubdir, entry.name); if (isPluginDir(subDir)) { installFromLocal(subDir, entry.name); installed.push(entry.name); } } if (installed.length === 0) { fatal(`No plugins found in ${url} -- expected index.js with register() at root or in plugins/*/`); } return installed; } fatal(`No plugins found in ${url} -- expected index.js with register() at root or plugins/*/ subdirs`); } finally { if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true }); } } function installPluginDeps(name) { const pluginDir = path.join(PLUGINS_DIR, name); // npm install if package.json exists const pkgJson = path.join(pluginDir, 'package.json'); if (fs.existsSync(pkgJson)) { console.log(`Installing npm dependencies for ${name}...`); execSync('npm install --omit=dev', { cwd: pluginDir, stdio: 'inherit' }); } // Check for apt.txt / post-install.sh (just warn -- can't run apt locally) if (fs.existsSync(path.join(pluginDir, 'apt.txt'))) { console.log(`WARNING ${name} has apt.txt -- system packages need Docker build or manual install`); } if (fs.existsSync(path.join(pluginDir, 'post-install.sh'))) { console.log(`WARNING ${name} has post-install.sh -- run it manually or rebuild Docker image`); } } // -- Remove ------------------------------------------------------------------ function removePlugin(name) { const pluginDir = path.join(PLUGINS_DIR, name); if (!fs.existsSync(pluginDir)) { fatal(`Plugin "${name}" not found in plugins/`); } fs.rmSync(pluginDir, { recursive: true }); removeFromConfig(name); console.log(`[ok] Removed plugin "${name}"`); } // -- List -------------------------------------------------------------------- function listPlugins() { const config = readConfig(); const configPlugins = getEnabledPlugins(config); if (!fs.existsSync(PLUGINS_DIR)) { console.log('No plugins directory.'); return; } const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true }); const plugins = entries .filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.')) .map(e => e.name); if (plugins.length === 0) { console.log('No plugins installed.'); return; } console.log('Installed plugins:\n'); for (const name of plugins.sort()) { const enabled = configPlugins.size === 0 || configPlugins.has(name); const status = enabled ? '[ok]' : 'o'; const hasTest = fs.existsSync(path.join(PLUGINS_DIR, name, `${name}.test.js`)) || fs.readdirSync(path.join(PLUGINS_DIR, name)).some(f => f.endsWith('.test.js')); const hasDeps = fs.existsSync(path.join(PLUGINS_DIR, name, 'apt.txt')) || fs.existsSync(path.join(PLUGINS_DIR, name, 'post-install.sh')); const hasPkg = fs.existsSync(path.join(PLUGINS_DIR, name, 'package.json')); const flags = [ hasTest ? 'tests' : null, hasDeps ? 'sys-deps' : null, hasPkg ? 'npm-deps' : null, ].filter(Boolean).join(', '); console.log(` ${status} ${name}${flags ? ` (${flags})` : ''}`); } if (configPlugins.size > 0) { console.log(`\n${configPlugins.size} plugin(s) enabled in camofox.config.json`); } else { console.log('\nNo plugins[] in config -- all plugins are loaded'); } } // -- Helpers ----------------------------------------------------------------- function copyDirSync(src, dest) { fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); // Skip .git, node_modules if (entry.name === '.git' || entry.name === 'node_modules') continue; if (entry.isDirectory()) { copyDirSync(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } function fatal(msg) { console.error(`Error: ${msg}`); process.exit(1); } // -- CLI --------------------------------------------------------------------- const [,, action, ...args] = process.argv; switch (action) { case 'install': { const source = args[0]; if (!source) fatal('Usage: plugin install '); const parsed = parseSource(source); const installed = parsed.type === 'git' ? installFromGit(parsed.url, parsed.name) : installFromLocal(parsed.path, parsed.name); for (const name of installed) { addToConfig(name); installPluginDeps(name); } console.log(`\n[ok] Installed: ${installed.join(', ')}`); console.log(' Restart the server to load new plugin(s).'); break; } case 'remove': { const name = args[0]; if (!name) fatal('Usage: plugin remove '); removePlugin(name); break; } case 'list': case 'ls': { listPlugins(); break; } default: console.log(`camofox plugin manager Usage: node scripts/plugin.js install Install from git URL or local path node scripts/plugin.js remove Remove a plugin node scripts/plugin.js list List installed plugins Sources: git:github.com/user/repo https://github.com/user/repo ./path/to/local/plugin`); if (action) process.exit(1); }