fix: avoid opencode shell file probes

This commit is contained in:
Affaan Mustafa
2026-04-29 23:18:56 -04:00
committed by Affaan Mustafa
parent 9627c201c7
commit affbd33485
2 changed files with 150 additions and 16 deletions

View File

@@ -43,6 +43,14 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
return path.join(worktreePath, p) return path.join(worktreePath, p)
} }
function hasProjectFile(relativePath: string): boolean {
try {
return fs.existsSync(resolvePath(relativePath))
} catch {
return false
}
}
const pendingToolChanges = new Map<string, { path: string; type: "added" | "modified" }>() const pendingToolChanges = new Map<string, { path: string; type: "added" | "modified" }>()
let writeCounter = 0 let writeCounter = 0
@@ -275,14 +283,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
log("info", `[ECC] Session started - profile=${currentProfile}`) log("info", `[ECC] Session started - profile=${currentProfile}`)
// Check for project-specific context files // Check for project-specific context files
try { if (hasProjectFile("CLAUDE.md")) {
const hasClaudeMd = await $`test -f ${worktree}/CLAUDE.md && echo "yes"`.text()
if (hasClaudeMd.trim() === "yes") {
log("info", "[ECC] Found CLAUDE.md - loading project context") log("info", "[ECC] Found CLAUDE.md - loading project context")
} }
} catch {
// No CLAUDE.md found
}
}, },
/** /**
@@ -400,7 +403,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
ECC_PLUGIN: "true", ECC_PLUGIN: "true",
ECC_HOOK_PROFILE: currentProfile, ECC_HOOK_PROFILE: currentProfile,
ECC_DISABLED_HOOKS: process.env.ECC_DISABLED_HOOKS || "", ECC_DISABLED_HOOKS: process.env.ECC_DISABLED_HOOKS || "",
PROJECT_ROOT: worktree || directory, PROJECT_ROOT: worktreePath,
} }
// Detect package manager // Detect package manager
@@ -411,12 +414,9 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
"package-lock.json": "npm", "package-lock.json": "npm",
} }
for (const [lockfile, pm] of Object.entries(lockfiles)) { for (const [lockfile, pm] of Object.entries(lockfiles)) {
try { if (hasProjectFile(lockfile)) {
await $`test -f ${worktree}/${lockfile}`
env.PACKAGE_MANAGER = pm env.PACKAGE_MANAGER = pm
break break
} catch {
// Not found, try next
} }
} }
@@ -430,11 +430,8 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
} }
const detected: string[] = [] const detected: string[] = []
for (const [file, lang] of Object.entries(langDetectors)) { for (const [file, lang] of Object.entries(langDetectors)) {
try { if (hasProjectFile(file)) {
await $`test -f ${worktree}/${file}`
detected.push(lang) detected.push(lang)
} catch {
// Not found
} }
} }
if (detected.length > 0) { if (detected.length > 0) {

View File

@@ -0,0 +1,137 @@
/**
* Tests for the published OpenCode hook plugin surface.
*/
const assert = require("node:assert")
const fs = require("node:fs")
const os = require("node:os")
const path = require("node:path")
const { spawnSync } = require("node:child_process")
const { pathToFileURL } = require("node:url")
function runTest(name, fn) {
return Promise.resolve()
.then(fn)
.then(() => {
console.log(`${name}`)
return { passed: 1, failed: 0 }
})
.catch((error) => {
console.log(`${name}`)
console.error(` ${error.stack || error.message}`)
return { passed: 0, failed: 1 }
})
}
async function loadPlugin() {
const repoRoot = path.join(__dirname, "..")
const buildResult = spawnSync("node", [path.join(repoRoot, "scripts", "build-opencode.js")], {
cwd: repoRoot,
encoding: "utf8",
})
assert.strictEqual(buildResult.status, 0, buildResult.stderr || buildResult.stdout)
const pluginUrl = pathToFileURL(
path.join(repoRoot, ".opencode", "dist", "plugins", "ecc-hooks.js")
).href
return import(pluginUrl)
}
function createClient() {
const logs = []
return {
logs,
app: {
log: ({ body }) => {
logs.push(body)
return Promise.resolve()
},
},
}
}
function createFailingShell() {
const calls = []
const shell = (strings, ...values) => {
calls.push(String.raw({ raw: strings }, ...values))
const error = new Error("OpenCode plugin file probes must not use shell commands")
return {
then: (_resolve, reject) => reject(error),
text: async () => {
throw error
},
}
}
shell.calls = calls
return shell
}
async function withTempProject(files, fn) {
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-opencode-plugin-"))
try {
for (const file of files) {
const filePath = path.join(projectDir, file)
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, "")
}
return await fn(projectDir)
} finally {
fs.rmSync(projectDir, { recursive: true, force: true })
}
}
async function main() {
console.log("\n=== Testing OpenCode plugin hooks ===\n")
const { ECCHooksPlugin } = await loadPlugin()
const tests = [
[
"shell.env detects project markers without shelling out to test -f",
async () => withTempProject(
["pnpm-lock.yaml", "tsconfig.json", "pyproject.toml"],
async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
const env = await hooks["shell.env"]()
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.strictEqual(env.PROJECT_ROOT, projectDir)
assert.strictEqual(env.PACKAGE_MANAGER, "pnpm")
assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python")
assert.strictEqual(env.PRIMARY_LANGUAGE, "typescript")
}
),
],
[
"session.created checks CLAUDE.md through fs instead of shell test",
async () => withTempProject(["CLAUDE.md"], async (projectDir) => {
const client = createClient()
const $ = createFailingShell()
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
await hooks["session.created"]()
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
assert.ok(
client.logs.some((entry) => entry.message === "[ECC] Found CLAUDE.md - loading project context"),
"Expected CLAUDE.md detection log"
)
}),
],
]
let passed = 0
let failed = 0
for (const [name, fn] of tests) {
const result = await runTest(name, fn)
passed += result.passed
failed += result.failed
}
console.log(`\nPassed: ${passed}`)
console.log(`Failed: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
}
main()