Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80d3d136c8 |
+62
-1
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user