UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

412 lines 15.2 kB
/** * Setup-detection helpers for dashboard routes. * These helpers keep project inspection and setup payload shaping out of the * main HTTP server so route code can stay focused on request handling. */ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { getAgentProfiles } from "../agents/registry.js"; const AGENT_PROFILES = getAgentProfiles(); const NODE_FRAMEWORK_PACKAGES = [ ["React", ["react"]], ["Vue", ["vue"]], ["Angular", ["@angular/core"]], ["Svelte", ["svelte"]], ["Express", ["express"]], ["Next.js", ["next"]], ["NestJS", ["@nestjs/core"]], ]; const BOUNDED_SETUP_DIRS = [ "src", "assets", "templates", "tests", "config", "scripts", "strands_agents", ]; /** Detect which supported agent surfaces already exist in the project. */ function detectScaffoldedAgents(projectPath) { return Object.fromEntries(AGENT_PROFILES.map((agent) => { const markers = [ agent.instructionFile, agent.settingsFile, agent.hookConfigFile, agent.hooksDir, ].filter((value) => typeof value === "string"); const present = markers.some((marker) => existsSync(join(projectPath, marker))); return [agent.id, present]; })); } /** * Detect existing goat-flow artifacts for setup prefill. * * Swallows unreadable skill roots because setup detection is advisory and * should not block the dashboard from rendering a recovery prompt. */ function detectExistingArtifacts(projectPath) { const existing = { skills: false, instructionsRepoWide: false, instructionsPathScoped: false, lessons: false, footguns: false, config: false, }; const skillRoots = [ ...new Set(AGENT_PROFILES.map((agent) => agent.skillsDir)), ]; for (const root of skillRoots) { const skillsDir = join(projectPath, root); if (existsSync(skillsDir)) { try { if (readdirSync(skillsDir).some((entry) => entry.startsWith("goat-"))) { existing.skills = true; break; } } catch { /* unreadable */ } } } existing.instructionsRepoWide = existsSync(join(projectPath, ".github", "copilot-instructions.md")); existing.instructionsPathScoped = existsSync(join(projectPath, ".github", "instructions")); existing.lessons = existsSync(join(projectPath, ".goat-flow", "lessons")) || existsSync(join(projectPath, "ai", "lessons")); existing.footguns = existsSync(join(projectPath, ".goat-flow", "footguns")) || existsSync(join(projectPath, "docs", "footguns")) || existsSync(join(projectPath, "docs", "footguns.md")); existing.config = existsSync(join(projectPath, ".goat-flow", "config.yaml")); return existing; } /** Detect non-goat-flow agent config files (.github/instructions, CLAUDE.md, etc.). */ function detectNonGoatFlowConfig(projectPath) { const nonGoatFlow = []; const checks = [ [[".github", "instructions"], ".github/instructions/"], [["CLAUDE.md"], "CLAUDE.md"], [["AGENTS.md"], "AGENTS.md"], [["CODEX.md"], "CODEX.md"], [[".cursorrules"], ".cursorrules"], ]; for (const [segments, label] of checks) { if (existsSync(join(projectPath, ...segments))) nonGoatFlow.push(label); } return nonGoatFlow; } /** Add a display label once while preserving first-seen order. */ function addLabel(labels, label) { if (!labels.includes(label)) labels.push(label); } /** Read and parse one root JSON file. Invalid or missing files are ignored. */ function readRootJson(projectPath, filename) { try { const parsed = JSON.parse(readFileSync(join(projectPath, filename), "utf-8")); return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : null; } catch { return null; } } /** Read one root text file; missing or unreadable files fallback to an empty string. */ function readRootText(projectPath, filename) { try { return readFileSync(join(projectPath, filename), "utf-8"); } catch { return ""; } } /** Check one root-level marker without recursing into large project trees. */ function rootExists(projectPath, filename) { return existsSync(join(projectPath, filename)); } /** Match one root directory entry; swallows unreadable roots as no match. */ function rootMatches(projectPath, pattern) { try { return readdirSync(projectPath).some((entry) => pattern.test(entry)); } catch { return false; } } /** Probe only known setup-relevant directories; stat failures fallback to false. */ function hasBoundedSetupDir(projectPath, dirname) { if (!BOUNDED_SETUP_DIRS.includes(dirname)) return false; try { return statSync(join(projectPath, dirname)).isDirectory(); } catch { return false; } } /** Return string-valued object entries from package-manager metadata. */ function objectAt(candidate) { if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) { return {}; } return Object.fromEntries(Object.entries(candidate).filter((entry) => typeof entry[1] === "string")); } function scriptCommand(scripts, exactKeys, fuzzyKeys = [], containsKeys = fuzzyKeys) { for (const key of exactKeys) { const command = scripts[key]; if (command && !isPlaceholderScript(command)) return command; } for (const key of fuzzyKeys) { const command = scripts[key]; if (command && !isPlaceholderScript(command)) return `npm run ${key}`; } const fuzzy = Object.keys(scripts).find((key) => containsKeys.some((candidate) => key.includes(candidate)) && scripts[key] !== undefined && !isPlaceholderScript(scripts[key] ?? "")); return fuzzy ? `npm run ${fuzzy}` : ""; } function composerScriptCommand(scripts, keys) { for (const key of keys) { const commandValue = scripts[key]; if (typeof commandValue === "string") return commandValue; if (Array.isArray(commandValue) && commandValue.every((entry) => typeof entry === "string")) { return commandValue.join(" && "); } } return ""; } /** Ignore scaffold placeholder scripts that would fail before exercising the project. */ function isPlaceholderScript(command) { const trimmed = command.trim(); return (trimmed === "" || /^echo\s+"Error:/.test(trimmed) || /^echo\s+"no\s+(test|build)/i.test(trimmed) || /^exit\s+1$/.test(trimmed) || /^echo\s+.*&&\s*exit\s+1$/.test(trimmed)); } function mergeCommands(target, next) { target.test ||= next.test ?? ""; target.lint ||= next.lint ?? ""; target.build ||= next.build ?? ""; target.format ||= next.format ?? ""; } function collectNodeSetup(projectPath, languages, frameworks) { const pkg = readRootJson(projectPath, "package.json"); if (!pkg) return {}; const runtimeDeps = objectAt(pkg["dependencies"]); const devDeps = objectAt(pkg["devDependencies"]); const peerDeps = objectAt(pkg["peerDependencies"]); const deps = { ...runtimeDeps, ...devDeps, ...peerDeps }; const scripts = objectAt(pkg["scripts"]); const hasTypeScript = "typescript" in deps || rootExists(projectPath, "tsconfig.json"); addLabel(languages, "JavaScript"); if (hasTypeScript) addLabel(languages, "TypeScript"); for (const [framework, packages] of NODE_FRAMEWORK_PACKAGES) { if (packages.some((pkgName) => pkgName in deps)) addLabel(frameworks, framework); } return { build: scriptCommand(scripts, ["build"], ["build"]), test: scriptCommand(scripts, ["test"], [ "e2e", "cypress", "spec", "test:unit", "test:e2e", "test:integration", "test", ], ["test"]), lint: scriptCommand(scripts, ["lint"], ["lint"]), format: scriptCommand(scripts, ["format", "format:check"], ["format"]), }; } /** Return Composer scripts only when the metadata is an object map. */ function composerScripts(composer) { const scripts = composer["scripts"]; return typeof scripts === "object" && scripts !== null && !Array.isArray(scripts) ? scripts : {}; } function collectPHPFrameworks(projectPath, deps, languages, frameworks) { addLabel(languages, "PHP"); if ("symfony/framework-bundle" in deps || rootExists(projectPath, "symfony.lock")) { addLabel(frameworks, "Symfony"); } if ("laravel/framework" in deps || rootExists(projectPath, "artisan")) { addLabel(frameworks, "Laravel"); } if ("twig/twig" in deps || "symfony/twig-bundle" in deps) { addLabel(languages, "Twig"); } } function collectPHPCommands(projectPath, scripts) { const hasPhpUnit = rootExists(projectPath, "phpunit.xml") || rootExists(projectPath, "phpunit.xml.dist"); const hasPhpStan = rootExists(projectPath, "phpstan.neon"); return { test: composerScriptCommand(scripts, ["test"]) || (hasPhpUnit ? "vendor/bin/phpunit" : ""), lint: composerScriptCommand(scripts, ["analyse", "lint"]) || (hasPhpStan ? "vendor/bin/phpstan analyse" : ""), format: composerScriptCommand(scripts, ["cs:check", "cs:fix"]), }; } function collectPHPSetup(projectPath, languages, frameworks) { const composer = readRootJson(projectPath, "composer.json"); if (!composer) return {}; const require = objectAt(composer["require"]); const requireDev = objectAt(composer["require-dev"]); const deps = { ...require, ...requireDev }; const scripts = composerScripts(composer); collectPHPFrameworks(projectPath, deps, languages, frameworks); return collectPHPCommands(projectPath, scripts); } function collectPythonSetup(projectPath, languages, commands) { if (rootExists(projectPath, "pyproject.toml") || rootExists(projectPath, "setup.py") || rootExists(projectPath, "setup.cfg") || rootExists(projectPath, "requirements.txt") || hasBoundedSetupDir(projectPath, "strands_agents")) { addLabel(languages, "Python"); commands.test ||= "pytest"; commands.lint ||= "ruff check"; } } function collectGoSetup(projectPath, languages, commands) { if (rootExists(projectPath, "go.mod")) { addLabel(languages, "Go"); commands.build ||= "go build ./..."; commands.test ||= "go test ./..."; commands.lint ||= "go vet ./..."; commands.format ||= "gofmt -l ."; } } function collectRustSetup(projectPath, languages, commands) { if (rootExists(projectPath, "Cargo.toml")) { addLabel(languages, "Rust"); commands.build ||= "cargo build"; commands.test ||= "cargo test"; commands.lint ||= "cargo clippy"; commands.format ||= "cargo fmt --check"; } } function collectRubySetup(projectPath, languages, commands) { if (rootExists(projectPath, "Gemfile")) { addLabel(languages, "Ruby"); commands.test ||= "bundle exec rspec"; commands.lint ||= "bundle exec rubocop"; } } function collectJavaSetup(projectPath, languages, commands) { if (rootExists(projectPath, "pom.xml") || rootMatches(projectPath, /^build\.gradle/)) { addLabel(languages, "Java"); if (rootExists(projectPath, "pom.xml")) { commands.build ||= "mvn package"; commands.test ||= "mvn test"; } else { commands.build ||= "gradle build"; commands.test ||= "gradle test"; } } } /** Detect shell support from root scripts or a bounded `scripts/` directory. */ function collectShellSetup(projectPath, languages) { if (rootMatches(projectPath, /^.+\.sh$/) || hasBoundedSetupDir(projectPath, "scripts")) { addLabel(languages, "Bash"); } } function collectOtherRootSetup(projectPath, languages, frameworks, commands) { collectPythonSetup(projectPath, languages, commands); collectGoSetup(projectPath, languages, commands); collectRustSetup(projectPath, languages, commands); collectRubySetup(projectPath, languages, commands); collectJavaSetup(projectPath, languages, commands); collectShellSetup(projectPath, languages); if (rootExists(projectPath, "Dockerfile")) { addLabel(frameworks, "Docker"); } } /** Fast first-render stack summary for `/api/setup/detect`. * This intentionally avoids the full stack detector and broad recursive glob probes. */ function detectFastSetupStack(projectPath) { const languages = []; const frameworks = []; const commands = { test: "", lint: "", build: "", format: "" }; mergeCommands(commands, collectNodeSetup(projectPath, languages, frameworks)); mergeCommands(commands, collectPHPSetup(projectPath, languages, frameworks)); collectOtherRootSetup(projectPath, languages, frameworks, commands); const webpackConfig = readRootText(projectPath, "webpack.config.js"); if (webpackConfig.includes("Encore")) addLabel(frameworks, "Webpack Encore"); if (hasBoundedSetupDir(projectPath, "templates") && languages.includes("PHP")) { addLabel(languages, "Twig"); } return { languages, frameworks, commands }; } /** * Build the full `/api/setup/detect` payload for one project path. * * @param projectPath - Target project root selected in the dashboard. * @returns Setup-view payload with fast stack hints, agents, and existing surfaces. */ export function buildSetupDetectPayload(projectPath) { const stack = detectFastSetupStack(projectPath); return { languages: stack.languages, frameworks: stack.frameworks, commands: stack.commands, agents: detectScaffoldedAgents(projectPath), existing: detectExistingArtifacts(projectPath), nonGoatFlow: detectNonGoatFlowConfig(projectPath), }; } /** * Heuristically treat a directory as a project when it has common repo markers. * * Swallows marker stat failures as non-matches because browse results * should survive unreadable children. * * @param dirPath - Candidate directory path from the browser route. * @returns True when any supported project or agent marker exists. */ export function isProjectDirectory(dirPath) { return [ "package.json", "go.mod", "Cargo.toml", "composer.json", "pyproject.toml", ...AGENT_PROFILES.map((agent) => agent.instructionFile), ].some((file) => { try { statSync(join(dirPath, file)); return true; } catch { return false; } }); } //# sourceMappingURL=setup-detect.js.map