257 lines
9.2 KiB
TypeScript
257 lines
9.2 KiB
TypeScript
import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
|
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
import { Type } from "@sinclair/typebox";
|
|
|
|
interface AuditResult {
|
|
outdated: string[];
|
|
vulnerabilities: string[];
|
|
unused: string[];
|
|
missing: string[];
|
|
summary: string;
|
|
}
|
|
|
|
export default function (pi: ExtensionAPI) {
|
|
async function parsePackageJson(cwd: string): Promise<{ deps: Record<string, string>; devDeps: Record<string, string>; scripts: Record<string, string> } | null> {
|
|
const pkgPath = join(cwd, "package.json");
|
|
if (!existsSync(pkgPath)) return null;
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
return {
|
|
deps: pkg.dependencies || {},
|
|
devDeps: pkg.devDependencies || {},
|
|
scripts: pkg.scripts || {},
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function runNpmAudit(cwd: string): Promise<string> {
|
|
try {
|
|
const result = await pi.exec("npm", ["audit", "--json"], { cwd });
|
|
if (result.stdout.trim()) {
|
|
try {
|
|
const audit = JSON.parse(result.stdout);
|
|
const meta = audit.metadata || {};
|
|
const vulns = meta.vulnerabilities || {};
|
|
const lines: string[] = [];
|
|
let totalVulns = 0;
|
|
for (const [severity, count] of Object.entries(vulns)) {
|
|
if (typeof count === "number" && count > 0) {
|
|
lines.push(` ${severity}: ${count}`);
|
|
totalVulns += count;
|
|
}
|
|
}
|
|
if (totalVulns === 0) return "\u2705 No known vulnerabilities found.";
|
|
return `\u26A0\uFE0F Found ${totalVulns} vulnerabilities:\n${lines.join("\n")}`;
|
|
} catch {
|
|
return result.stdout.substring(0, 1000);
|
|
}
|
|
}
|
|
return result.stderr.trim() || "No audit data available.";
|
|
} catch (err: any) {
|
|
return `Audit error: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
async function runNpmOutdated(cwd: string): Promise<string[]> {
|
|
try {
|
|
const result = await pi.exec("npm", ["outdated", "--json"], { cwd });
|
|
if (result.stdout.trim()) {
|
|
try {
|
|
const outdated = JSON.parse(result.stdout);
|
|
const lines: string[] = [];
|
|
for (const [pkg, info] of Object.entries(outdated)) {
|
|
const i = info as any;
|
|
lines.push(` ${pkg}: ${i.current || "?"} \u2192 ${i.latest || "?"}`);
|
|
}
|
|
return lines;
|
|
} catch {
|
|
return [result.stdout.substring(0, 500)];
|
|
}
|
|
}
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function findUnusedDeps(cwd: string, deps: Record<string, string>): Promise<string[]> {
|
|
const unused: string[] = [];
|
|
const depNames = Object.keys(deps);
|
|
if (depNames.length === 0) return unused;
|
|
|
|
for (const dep of depNames) {
|
|
try {
|
|
// Search for import/require of this dep in source files
|
|
const result = await pi.exec(
|
|
"grep",
|
|
["-r", "-l", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx", "--include=*.mjs", "--include=*.cjs", dep, "."],
|
|
{ cwd },
|
|
);
|
|
if (result.code !== 0 || !result.stdout.trim()) {
|
|
// Also check if referenced in config files
|
|
const configResult = await pi.exec(
|
|
"grep",
|
|
["-r", "-l", "--include=*.json", "--include=*.yaml", "--include=*.yml", "--include=*.toml", "--include=*.config.*", dep, "."],
|
|
{ cwd },
|
|
);
|
|
// Exclude package.json and lock files
|
|
const configFiles = (configResult.stdout || "").split("\n").filter(
|
|
(f) => f.trim() && !f.includes("package.json") && !f.includes("package-lock") && !f.includes("node_modules"),
|
|
);
|
|
if (configFiles.length === 0) {
|
|
unused.push(dep);
|
|
}
|
|
}
|
|
} catch {
|
|
// grep failed, skip
|
|
}
|
|
}
|
|
return unused;
|
|
}
|
|
|
|
async function fullAudit(cwd: string): Promise<AuditResult> {
|
|
const pkg = await parsePackageJson(cwd);
|
|
if (!pkg) {
|
|
return {
|
|
outdated: [],
|
|
vulnerabilities: ["No package.json found in current directory."],
|
|
unused: [],
|
|
missing: [],
|
|
summary: "\u274C No package.json found. Navigate to a Node.js project directory.",
|
|
};
|
|
}
|
|
|
|
const allDeps = { ...pkg.deps, ...pkg.devDeps };
|
|
const totalDeps = Object.keys(allDeps).length;
|
|
|
|
// Run checks in parallel
|
|
const [outdated, securityReport, unused] = await Promise.all([
|
|
runNpmOutdated(cwd),
|
|
runNpmAudit(cwd),
|
|
findUnusedDeps(cwd, pkg.deps),
|
|
]);
|
|
|
|
// Check for missing deps (referenced but not in package.json)
|
|
const missing: string[] = [];
|
|
try {
|
|
const result = await pi.exec(
|
|
"grep",
|
|
["-r", "-h", "-o", "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx",
|
|
`from ['"][^./][^'"]*['"]`, "."],
|
|
{ cwd },
|
|
);
|
|
if (result.stdout) {
|
|
const imports = new Set(
|
|
result.stdout.split("\n")
|
|
.map((line) => {
|
|
const match = line.match(/from\s+['"]([^./][^'"]*)['"]/);
|
|
return match ? match[1].split("/")[0] : null;
|
|
})
|
|
.filter((name): name is string => name !== null && !name.startsWith("node:")),
|
|
);
|
|
for (const imp of imports) {
|
|
if (!allDeps[imp] && !imp.startsWith("@")) {
|
|
missing.push(imp);
|
|
}
|
|
}
|
|
}
|
|
} catch { /* ignore grep errors */ }
|
|
|
|
const summaryLines = [
|
|
`\u{1F4E6} Dependencies: ${totalDeps} total (${Object.keys(pkg.deps).length} prod, ${Object.keys(pkg.devDeps).length} dev)`,
|
|
`\u{1F504} Outdated: ${outdated.length > 0 ? outdated.length + " packages" : "all up to date \u2705"}`,
|
|
`\u{1F6E1}\uFE0F Security: ${securityReport}`,
|
|
`\u{1F5D1}\uFE0F Potentially unused: ${unused.length > 0 ? unused.join(", ") : "none detected \u2705"}`,
|
|
];
|
|
if (missing.length > 0) {
|
|
summaryLines.push(`\u26A0\uFE0F Possibly missing: ${missing.join(", ")}`);
|
|
}
|
|
|
|
return {
|
|
outdated,
|
|
vulnerabilities: [securityReport],
|
|
unused,
|
|
missing,
|
|
summary: summaryLines.join("\n"),
|
|
};
|
|
}
|
|
|
|
pi.registerTool({
|
|
name: "audit_dependencies",
|
|
label: "Audit Dependencies",
|
|
description: "Scan project dependencies for outdated packages, security vulnerabilities, and unused dependencies. Optionally auto-fix issues.",
|
|
parameters: Type.Object({
|
|
action: Type.Optional(Type.String({ description: "Action: 'scan' (default), 'fix', 'security'" })),
|
|
}),
|
|
execute: async (args, ctx) => {
|
|
const action = (args.action || "scan").toLowerCase();
|
|
|
|
if (action === "fix") {
|
|
const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd });
|
|
const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd });
|
|
return {
|
|
output: `\u{1F527} Auto-fix results:\n\nnpm update:\n${updateResult.stdout || updateResult.stderr}\n\nnpm audit fix:\n${auditFixResult.stdout || auditFixResult.stderr}`,
|
|
};
|
|
}
|
|
|
|
if (action === "security") {
|
|
const report = await runNpmAudit(ctx.cwd);
|
|
return { output: `\u{1F6E1}\uFE0F Security Report:\n\n${report}` };
|
|
}
|
|
|
|
const result = await fullAudit(ctx.cwd);
|
|
return { output: result.summary };
|
|
},
|
|
});
|
|
|
|
pi.registerCommand("audit", {
|
|
description: "Audit dependencies: /audit [fix|security]",
|
|
handler: async (args, ctx) => {
|
|
const subcommand = args.trim().toLowerCase();
|
|
|
|
if (subcommand === "fix") {
|
|
ctx.ui.notify("\u{1F527} Running npm update and audit fix...", "info");
|
|
const updateResult = await pi.exec("npm", ["update"], { cwd: ctx.cwd });
|
|
const auditFixResult = await pi.exec("npm", ["audit", "fix"], { cwd: ctx.cwd });
|
|
ctx.ui.notify(
|
|
`\u{1F527} Fix Results:\n\nnpm update:\n${(updateResult.stdout || updateResult.stderr || "done").substring(0, 500)}\n\nnpm audit fix:\n${(auditFixResult.stdout || auditFixResult.stderr || "done").substring(0, 500)}`,
|
|
"info",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (subcommand === "security") {
|
|
ctx.ui.notify("\u{1F6E1}\uFE0F Running security audit...", "info");
|
|
const report = await runNpmAudit(ctx.cwd);
|
|
ctx.ui.notify(`\u{1F6E1}\uFE0F Security Report:\n\n${report}`, "info");
|
|
return;
|
|
}
|
|
|
|
ctx.ui.notify("\u{1F50D} Scanning dependencies...", "info");
|
|
const result = await fullAudit(ctx.cwd);
|
|
|
|
let output = `\u{1F4CB} Dependency Audit Report\n${'\u2500'.repeat(40)}\n\n${result.summary}`;
|
|
|
|
if (result.outdated.length > 0) {
|
|
output += `\n\n\u{1F504} Outdated Packages:\n${result.outdated.join("\n")}`;
|
|
}
|
|
|
|
if (result.unused.length > 0) {
|
|
output += `\n\n\u{1F5D1}\uFE0F Potentially Unused:\n ${result.unused.join(", ")}`;
|
|
output += `\n Run: npm uninstall ${result.unused.join(" ")}`;
|
|
}
|
|
|
|
if (result.missing.length > 0) {
|
|
output += `\n\n\u26A0\uFE0F Possibly Missing:\n ${result.missing.join(", ")}`;
|
|
output += `\n Run: npm install ${result.missing.join(" ")}`;
|
|
}
|
|
|
|
output += `\n\n\u{1F4A1} Tip: /audit fix to auto-update, /audit security for vuln details`;
|
|
|
|
ctx.ui.notify(output, "info");
|
|
},
|
|
});
|
|
}
|