fix(opencode): honor -e/--regexp as pattern and surface non-ENOENT plugin stat errors

This commit is contained in:
rgb-vgx 2026-10-17 19:56:38 +07:00
parent 45d6c48695
commit cfdf31f72a
4 changed files with 62 additions and 7 deletions

View file

@ -79,9 +79,16 @@ export function extractAugmentContext(stderr) {
return output.slice(marker).trim();
}
/**
* rg/grep flags whose following argv token IS the search pattern
* (`rg -e foo file.ts`, `grep --regexp foo .`). They must not be lumped in
* with value-consuming flags — skipping the value would augment on the
* filename instead of the search.
*/
const PATTERN_FLAGS = new Set(['-e', '--regexp', '--expression']);
/** Flags of rg/grep that consume the following argv token as a value. */
const FLAGS_WITH_VALUES = new Set([
'-e',
'-f',
'-m',
'-A',
@ -103,18 +110,25 @@ export function extractBashSearchPattern(command) {
if (typeof command !== 'string' || !/\brg\b|\bgrep\b/.test(command)) return null;
const tokens = command.split(/\s+/);
let foundCmd = false;
let skipNext = false;
// 'skip' = drop the next token (flag value); 'pattern' = take it as the search.
let pending = null;
for (const token of tokens) {
if (skipNext) {
skipNext = false;
if (pending === 'skip') {
pending = null;
continue;
}
if (pending === 'pattern') {
pending = null;
const cleaned = token.replace(/['"]/g, '');
return cleaned.length >= 3 ? cleaned : null;
}
if (!foundCmd) {
if (/\brg$|\bgrep$/.test(token)) foundCmd = true;
continue;
}
if (token.startsWith('-')) {
if (FLAGS_WITH_VALUES.has(token)) skipNext = true;
if (PATTERN_FLAGS.has(token)) pending = 'pattern';
else if (FLAGS_WITH_VALUES.has(token)) pending = 'skip';
continue;
}
const cleaned = token.replace(/['"]/g, '');

View file

@ -205,8 +205,11 @@ async function removeFile(filePath: string, dryRun: boolean): Promise<boolean> {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) return false;
} catch {
return false;
} catch (err) {
// Only "not there" is a clean no-op; anything else (EACCES, EIO, ...)
// must surface — the caller records it in result.errors.
if (isEnoent(err)) return false;
throw err;
}
if (!dryRun) await fs.rm(filePath, { force: true });
return true;

View file

@ -72,6 +72,14 @@ describe('extractBashSearchPattern', () => {
expect(extractBashSearchPattern('grep -r UserService .')).toBe('UserService');
});
it('treats -e/--regexp/--expression as the pattern itself, not a value to skip', () => {
expect(extractBashSearchPattern('rg -e UserService .')).toBe('UserService');
expect(extractBashSearchPattern('grep -e UserService file.ts')).toBe('UserService');
expect(extractBashSearchPattern('rg --regexp UserService .')).toBe('UserService');
// The pattern flag must still win over a following positional filename.
expect(extractBashSearchPattern('rg -n -e UserService setup.ts')).toBe('UserService');
});
it('rejects short tokens and non-search commands', () => {
expect(extractBashSearchPattern('rg -n "ab" .')).toBeNull();
expect(extractBashSearchPattern('npm test')).toBeNull();

View file

@ -444,6 +444,36 @@ describe('uninstallCommand', () => {
expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus');
});
it('reports a non-ENOENT stat error on the plugin file instead of silently skipping', async () => {
const pluginFile = path.join(tempHome, '.config', 'opencode', 'plugins', 'gitnexus.js');
await fs.mkdir(path.dirname(pluginFile), { recursive: true });
await fs.writeFile(pluginFile, '// gitnexus', 'utf-8');
// Simulate EACCES on the plugin stat only; every other stat passes through.
const realStat = fs.stat.bind(fs);
const statSpy = vi.spyOn(fs, 'stat').mockImplementation((async (p: any, ...rest: any[]) => {
if (String(p).endsWith(path.join('plugins', 'gitnexus.js'))) {
const err: any = new Error(`EACCES: permission denied, stat '${p}'`);
err.code = 'EACCES';
throw err;
}
return (realStat as any)(p, ...rest);
}) as any);
const logSpy = vi.spyOn(console, 'log');
const uninstallCommand = await importUninstall();
await uninstallCommand({ force: true });
// Before the fix, removeFile swallowed every stat error → plugin left in
// place with no error reported. Now EACCES surfaces in the Errors block.
const output = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(output).toContain('hooks plugin');
expect(statSpy).toHaveBeenCalled();
// The plugin file was never reached (rm), so it still exists.
expect(await fs.readFile(pluginFile, 'utf-8')).toBe('// gitnexus');
expect(process.exitCode).toBe(1);
});
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');