Compare commits

...

1 Commits

Author SHA1 Message Date
ByteYue 80d3d136c8 feat(plugin): validate plugin structure on install and update 2026-03-24 20:27:47 +08:00
2 changed files with 123 additions and 3 deletions
+62 -1
View File
@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { PLUGINS_DIR } from './discovery.js';
import { listPlugins, uninstallPlugin, updatePlugin, _parseSource } from './plugin.js';
import { listPlugins, uninstallPlugin, updatePlugin, _parseSource, _validatePluginStructure } from './plugin.js';
describe('parseSource', () => {
it('parses github:user/repo format', () => {
@@ -41,6 +41,67 @@ describe('parseSource', () => {
});
});
describe('validatePluginStructure', () => {
const testDir = path.join(PLUGINS_DIR, '__test-validate__');
beforeEach(() => {
fs.mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
try { fs.rmSync(testDir, { recursive: true }); } catch {}
});
it('returns invalid for non-existent directory', () => {
const res = _validatePluginStructure(path.join(PLUGINS_DIR, '__does_not_exist__'));
expect(res.valid).toBe(false);
expect(res.errors[0]).toContain('does not exist');
});
it('returns invalid for empty directory', () => {
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(false);
expect(res.errors[0]).toContain('No command files found');
});
it('returns valid for YAML plugin', () => {
fs.writeFileSync(path.join(testDir, 'cmd.yaml'), 'site: test');
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(true);
expect(res.errors).toHaveLength(0);
});
it('returns valid for JS plugin', () => {
fs.writeFileSync(path.join(testDir, 'cmd.js'), 'console.log("hi");');
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(true);
expect(res.errors).toHaveLength(0);
});
it('returns invalid for TS plugin without package.json', () => {
fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");');
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(false);
expect(res.errors[0]).toContain('contains .ts files but no package.json');
});
it('returns invalid for TS plugin with missing type: module', () => {
fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");');
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ name: 'test' }));
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(false);
expect(res.errors[0]).toContain('must have "type": "module"');
});
it('returns valid for TS plugin with correct package.json', () => {
fs.writeFileSync(path.join(testDir, 'cmd.ts'), 'console.log("hi");');
fs.writeFileSync(path.join(testDir, 'package.json'), JSON.stringify({ type: 'module' }));
const res = _validatePluginStructure(testDir);
expect(res.valid).toBe(true);
expect(res.errors).toHaveLength(0);
});
});
describe('listPlugins', () => {
const testDir = path.join(PLUGINS_DIR, '__test-list-plugin__');
+61 -2
View File
@@ -18,6 +18,53 @@ export interface PluginInfo {
source?: string;
}
// ── Validation helpers ──────────────────────────────────────────────────────
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Validate that a downloaded plugin directory is a structurally valid plugin.
* Checks for at least one command file (.yaml, .yml, .ts, .js) and a valid
* package.json if it contains .ts files.
*/
export function validatePluginStructure(pluginDir: string): ValidationResult {
const errors: string[] = [];
if (!fs.existsSync(pluginDir)) {
return { valid: false, errors: ['Plugin directory does not exist'] };
}
const files = fs.readdirSync(pluginDir);
const hasYaml = files.some(f => f.endsWith('.yaml') || f.endsWith('.yml'));
const hasTs = files.some(f => f.endsWith('.ts') && !f.endsWith('.d.ts') && !f.endsWith('.test.ts'));
const hasJs = files.some(f => f.endsWith('.js') && !f.endsWith('.d.js'));
if (!hasYaml && !hasTs && !hasJs) {
errors.push(`No command files found in plugin directory. A plugin must contain at least one .yaml, .ts, or .js command file.`);
}
if (hasTs) {
const pkgJsonPath = path.join(pluginDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
errors.push(`Plugin contains .ts files but no package.json. A package.json with "type": "module" and "@jackwener/opencli" peer dependency is required for TS plugins.`);
} else {
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
if (pkg.type !== 'module') {
errors.push(`Plugin package.json must have "type": "module" for TypeScript plugins.`);
}
} catch {
errors.push(`Plugin package.json is malformed or invalid JSON.`);
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* Shared post-install lifecycle: npm install → host symlink → TS transpile.
* Called by both installPlugin() and updatePlugin().
@@ -78,6 +125,13 @@ export function installPlugin(source: string): string {
throw new Error(`Failed to clone plugin: ${err.message}`);
}
const validation = validatePluginStructure(targetDir);
if (!validation.valid) {
// If validation fails, clean up the cloned directory and abort
fs.rmSync(targetDir, { recursive: true, force: true });
throw new Error(`Invalid plugin structure:\n- ${validation.errors.join('\n- ')}`);
}
postInstallLifecycle(targetDir);
return name;
}
@@ -112,6 +166,11 @@ export function updatePlugin(name: string): void {
throw new Error(`Failed to update plugin: ${err.message}`);
}
const validation = validatePluginStructure(targetDir);
if (!validation.valid) {
log.warn(`Plugin "${name}" updated, but structure is now invalid:\n- ${validation.errors.join('\n- ')}`);
}
postInstallLifecycle(targetDir);
}
@@ -163,7 +222,7 @@ function scanPluginCommands(dir: string): string[] {
/** Get git remote origin URL */
function getPluginSource(dir: string): string | undefined {
try {
return execSync('git config --get remote.origin.url', {
return execFileSync('git', ['config', '--get', 'remote.origin.url'], {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
@@ -275,4 +334,4 @@ function transpilePluginTs(pluginDir: string): void {
}
}
export { parseSource as _parseSource };
export { parseSource as _parseSource, validatePluginStructure as _validatePluginStructure };