Files
english/.opencode/plugin/lib/project-detector.cjs
2026-04-12 01:06:31 +07:00

475 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* project-detector.cjs - Project and environment detection logic
*
* Extracted from session-init.cjs for reuse in both Claude hooks and OpenCode plugins.
* Detects project type, package manager, framework, and runtime versions.
*
* @module project-detector
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync } = require('child_process');
// ═══════════════════════════════════════════════════════════════════════════
// SAFE EXECUTION HELPERS
// ═══════════════════════════════════════════════════════════════════════════
/**
* Safely execute shell command with optional timeout
* @param {string} cmd - Command to execute
* @param {number} [timeoutMs=5000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execSafe(cmd, timeoutMs = 5000) {
try {
return execSync(cmd, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
/**
* Safely execute a binary with arguments (no shell interpolation)
* @param {string} binary - Path to the executable
* @param {string[]} args - Arguments array
* @param {number} [timeoutMs=2000] - Timeout in milliseconds
* @returns {string|null} Output or null on error
*/
function execFileSafe(binary, args, timeoutMs = 2000) {
try {
return execFileSync(binary, args, {
encoding: 'utf8',
timeout: timeoutMs,
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// PYTHON DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Validate that a path is a file and doesn't contain shell metacharacters
* @param {string} p - Path to validate
* @returns {boolean}
*/
function isValidPythonPath(p) {
if (!p || typeof p !== 'string') return false;
if (/[;&|`$(){}[\]<>!#*?]/.test(p)) return false;
try {
const stat = fs.statSync(p);
return stat.isFile();
} catch (e) {
return false;
}
}
/**
* Build platform-specific Python paths for fast filesystem check
* @returns {string[]} Array of potential Python paths
*/
function getPythonPaths() {
const paths = [];
if (process.env.PYTHON_PATH) {
paths.push(process.env.PYTHON_PATH);
}
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA;
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
if (localAppData) {
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python.exe'));
paths.push(path.join(localAppData, 'Microsoft', 'WindowsApps', 'python3.exe'));
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(localAppData, 'Programs', 'Python', `Python${ver}`, 'python.exe'));
}
}
for (const ver of ['313', '312', '311', '310', '39']) {
paths.push(path.join(programFiles, `Python${ver}`, 'python.exe'));
paths.push(path.join(programFilesX86, `Python${ver}`, 'python.exe'));
}
paths.push('C:\\Python313\\python.exe');
paths.push('C:\\Python312\\python.exe');
paths.push('C:\\Python311\\python.exe');
paths.push('C:\\Python310\\python.exe');
paths.push('C:\\Python39\\python.exe');
} else {
paths.push('/usr/bin/python3');
paths.push('/usr/local/bin/python3');
paths.push('/opt/homebrew/bin/python3');
paths.push('/opt/homebrew/bin/python');
paths.push('/usr/bin/python');
paths.push('/usr/local/bin/python');
}
return paths;
}
/**
* Find Python binary using fast `which` lookup first, then filesystem check
* @returns {string|null} Python binary path or null
*/
function findPythonBinary() {
// Fast path: try `which` command first (10ms vs 2000ms per path)
if (process.platform !== 'win32') {
const whichPython3 = execSafe('which python3', 500);
if (whichPython3 && isValidPythonPath(whichPython3)) return whichPython3;
const whichPython = execSafe('which python', 500);
if (whichPython && isValidPythonPath(whichPython)) return whichPython;
} else {
// Windows: try `where` command
const wherePython = execSafe('where python', 500);
if (wherePython) {
const firstPath = wherePython.split('\n')[0].trim();
if (isValidPythonPath(firstPath)) return firstPath;
}
}
// Fallback: check known paths
const paths = getPythonPaths();
for (const p of paths) {
if (isValidPythonPath(p)) return p;
}
return null;
}
/**
* Get Python version with optimized detection
* @returns {string|null} Python version string or null
*/
function getPythonVersion() {
const pythonPath = findPythonBinary();
if (pythonPath) {
const result = execFileSafe(pythonPath, ['--version']);
if (result) return result;
}
const commands = ['python3', 'python'];
for (const cmd of commands) {
const result = execFileSafe(cmd, ['--version']);
if (result) return result;
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// GIT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Check if current directory is inside a git repository (fast check)
* Uses filesystem traversal instead of git command to avoid command failures
* @param {string} [startDir] - Directory to check from (defaults to cwd)
* @returns {boolean}
*/
function isGitRepo(startDir) {
let dir;
try {
dir = startDir || process.cwd();
} catch (e) {
// CWD deleted or inaccessible
return false;
}
const root = path.parse(dir).root;
while (dir !== root) {
if (fs.existsSync(path.join(dir, '.git'))) return true;
dir = path.dirname(dir);
}
return fs.existsSync(path.join(root, '.git'));
}
/**
* Get git remote URL
* @returns {string|null}
*/
function getGitRemoteUrl() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['config', '--get', 'remote.origin.url']);
}
/**
* Get current git branch
* @returns {string|null}
*/
function getGitBranch() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['branch', '--show-current']);
}
/**
* Get git repository root
* @returns {string|null}
*/
function getGitRoot() {
if (!isGitRepo()) return null;
return execFileSafe('git', ['rev-parse', '--show-toplevel']);
}
// ═══════════════════════════════════════════════════════════════════════════
// PROJECT DETECTION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect project type based on workspace indicators
* @param {string} [configOverride] - Manual override from config
* @returns {'monorepo' | 'library' | 'single-repo'}
*/
function detectProjectType(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('pnpm-workspace.yaml')) return 'monorepo';
if (fs.existsSync('lerna.json')) return 'monorepo';
if (fs.existsSync('package.json')) {
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
if (pkg.workspaces) return 'monorepo';
if (pkg.main || pkg.exports) return 'library';
} catch (e) { /* ignore */ }
}
return 'single-repo';
}
/**
* Detect package manager from lock files
* @param {string} [configOverride] - Manual override from config
* @returns {'npm' | 'pnpm' | 'yarn' | 'bun' | null}
*/
function detectPackageManager(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (fs.existsSync('bun.lockb')) return 'bun';
if (fs.existsSync('pnpm-lock.yaml')) return 'pnpm';
if (fs.existsSync('yarn.lock')) return 'yarn';
if (fs.existsSync('package-lock.json')) return 'npm';
return null;
}
/**
* Detect framework from package.json dependencies
* @param {string} [configOverride] - Manual override from config
* @returns {string|null}
*/
function detectFramework(configOverride) {
if (configOverride && configOverride !== 'auto') return configOverride;
if (!fs.existsSync('package.json')) return null;
try {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (deps['next']) return 'next';
if (deps['nuxt']) return 'nuxt';
if (deps['astro']) return 'astro';
if (deps['@remix-run/node'] || deps['@remix-run/react']) return 'remix';
if (deps['svelte'] || deps['@sveltejs/kit']) return 'svelte';
if (deps['vue']) return 'vue';
if (deps['react']) return 'react';
if (deps['express']) return 'express';
if (deps['fastify']) return 'fastify';
if (deps['hono']) return 'hono';
if (deps['elysia']) return 'elysia';
return null;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CODING LEVEL
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get coding level style name mapping
* @param {number} level - Coding level (0-5)
* @returns {string} Style name
*/
function getCodingLevelStyleName(level) {
const styleMap = {
0: 'coding-level-0-eli5',
1: 'coding-level-1-junior',
2: 'coding-level-2-mid',
3: 'coding-level-3-senior',
4: 'coding-level-4-lead',
5: 'coding-level-5-god'
};
return styleMap[level] || 'coding-level-5-god';
}
/**
* Get coding level guidelines by reading from output-styles .md files
* @param {number} level - Coding level (-1 to 5)
* @param {string} [configDir] - Config directory path
* @returns {string|null} Guidelines text or null if disabled
*/
function getCodingLevelGuidelines(level, configDir) {
if (level === -1 || level === null || level === undefined) return null;
const styleName = getCodingLevelStyleName(level);
const basePath = configDir || path.join(process.cwd(), '.claude');
const stylePath = path.join(basePath, 'output-styles', `${styleName}.md`);
try {
if (!fs.existsSync(stylePath)) return null;
const content = fs.readFileSync(stylePath, 'utf8');
const withoutFrontmatter = content.replace(/^---[\s\S]*?---\n*/, '').trim();
return withoutFrontmatter;
} catch (e) {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// CONTEXT OUTPUT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Build context summary for output (compact, single line)
* @param {Object} config - Loaded config
* @param {Object} detections - Project detections
* @param {{ path: string|null, resolvedBy: string|null }} resolved - Plan resolution
* @param {string|null} gitRoot - Git repository root
* @returns {string}
*/
function buildContextOutput(config, detections, resolved, gitRoot) {
const lines = [`Project: ${detections.type || 'unknown'}`];
if (detections.pm) lines.push(`PM: ${detections.pm}`);
lines.push(`Plan naming: ${config.plan.namingFormat}`);
if (gitRoot && gitRoot !== process.cwd()) {
lines.push(`Root: ${gitRoot}`);
}
if (resolved.path) {
if (resolved.resolvedBy === 'session') {
lines.push(`Plan: ${resolved.path}`);
} else {
lines.push(`Suggested: ${resolved.path}`);
}
}
return lines.join(' | ');
}
// ═══════════════════════════════════════════════════════════════════════════
// MAIN ENTRY POINT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Detect all project information
*
* @param {Object} [options]
* @param {Object} [options.configOverrides] - Override auto-detection
* @returns {{
* type: 'monorepo' | 'library' | 'single-repo',
* packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun' | null,
* framework: string | null,
* pythonVersion: string | null,
* nodeVersion: string,
* gitBranch: string | null,
* gitRoot: string | null,
* gitUrl: string | null,
* osPlatform: string,
* user: string,
* locale: string,
* timezone: string
* }}
*/
function detectProject(options = {}) {
const { configOverrides = {} } = options;
return {
type: detectProjectType(configOverrides.type),
packageManager: detectPackageManager(configOverrides.packageManager),
framework: detectFramework(configOverrides.framework),
pythonVersion: getPythonVersion(),
nodeVersion: process.version,
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
gitUrl: getGitRemoteUrl(),
osPlatform: process.platform,
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
};
}
/**
* Build static environment info object
* @param {string} [configDir] - Config directory path
* @returns {Object} Static environment info
*/
function buildStaticEnv(configDir) {
return {
nodeVersion: process.version,
pythonVersion: getPythonVersion(),
osPlatform: process.platform,
gitUrl: getGitRemoteUrl(),
gitBranch: getGitBranch(),
gitRoot: getGitRoot(),
user: process.env.USERNAME || process.env.USER || process.env.LOGNAME || os.userInfo().username,
locale: process.env.LANG || '',
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
configDir: configDir || path.join(process.cwd(), '.claude')
};
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS
// ═══════════════════════════════════════════════════════════════════════════
module.exports = {
// Main entry points
detectProject,
buildStaticEnv,
// Detection functions
detectProjectType,
detectPackageManager,
detectFramework,
// Python detection
getPythonVersion,
findPythonBinary,
getPythonPaths,
isValidPythonPath,
// Git detection
isGitRepo,
getGitRemoteUrl,
getGitBranch,
getGitRoot,
// Coding level
getCodingLevelStyleName,
getCodingLevelGuidelines,
// Output
buildContextOutput,
// Helpers
execSafe,
execFileSafe
};