camofox-browser/scripts/plugin.js

343 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* camofox plugin manager -- install, remove, and list plugins.
*
* Usage:
* node scripts/plugin.js install <source> Install a plugin from git URL or local path
* node scripts/plugin.js remove <name> 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 <git-url|local-path>');
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 <name>');
removePlugin(name);
break;
}
case 'list':
case 'ls': {
listPlugins();
break;
}
default:
console.log(`camofox plugin manager
Usage:
node scripts/plugin.js install <source> Install from git URL or local path
node scripts/plugin.js remove <name> 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);
}