Agent-JAE/default-extensions/env-manager.ts
2026-03-24 02:53:36 +01:00

295 lines
11 KiB
TypeScript

import type { ExtensionAPI } from "@jaeswift/jae-coding-agent";
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { join, relative, basename } from "node:path";
import { Type } from "@sinclair/typebox";
interface EnvFile {
name: string;
path: string;
vars: Map<string, string>;
}
export default function (pi: ExtensionAPI) {
function parseEnvFile(filePath: string): Map<string, string> {
const vars = new Map<string, string>();
if (!existsSync(filePath)) return vars;
try {
const content = readFileSync(filePath, "utf-8");
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIndex = trimmed.indexOf("=");
if (eqIndex === -1) continue;
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Strip quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
vars.set(key, value);
}
} catch { /* ignore read errors */ }
return vars;
}
function findEnvFiles(cwd: string): EnvFile[] {
const envFiles: EnvFile[] = [];
function scanDir(dir: string, depth: number = 0): void {
if (depth > 3) return; // Don't go too deep
try {
const entries = readdirSync(dir);
for (const entry of entries) {
if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "build") continue;
const fullPath = join(dir, entry);
try {
const stat = statSync(fullPath);
if (stat.isFile() && (entry.startsWith(".env") || entry.endsWith(".env"))) {
envFiles.push({
name: relative(cwd, fullPath) || entry,
path: fullPath,
vars: parseEnvFile(fullPath),
});
} else if (stat.isDirectory() && depth < 3) {
scanDir(fullPath, depth + 1);
}
} catch { /* skip inaccessible */ }
}
} catch { /* skip unreadable dirs */ }
}
scanDir(cwd);
return envFiles;
}
function maskValue(value: string): string {
if (value.length <= 4) return "****";
return value.substring(0, 2) + "*".repeat(Math.min(value.length - 4, 20)) + value.substring(value.length - 2);
}
function diffEnvFiles(fileA: EnvFile, fileB: EnvFile): string {
const allKeys = new Set([...fileA.vars.keys(), ...fileB.vars.keys()]);
const lines: string[] = [
`\u{1F504} Diff: ${fileA.name} vs ${fileB.name}`,
`${'\u2500'.repeat(50)}`,
];
let onlyA: string[] = [];
let onlyB: string[] = [];
let different: string[] = [];
let same = 0;
for (const key of Array.from(allKeys).sort()) {
const hasA = fileA.vars.has(key);
const hasB = fileB.vars.has(key);
if (hasA && !hasB) {
onlyA.push(key);
} else if (!hasA && hasB) {
onlyB.push(key);
} else if (hasA && hasB) {
const valA = fileA.vars.get(key)!;
const valB = fileB.vars.get(key)!;
if (valA !== valB) {
different.push(` ${key}: ${maskValue(valA)} \u2192 ${maskValue(valB)}`);
} else {
same++;
}
}
}
if (onlyA.length > 0) lines.push(`\n\u{1F7E2} Only in ${fileA.name}:\n ${onlyA.join(", ")}`);
if (onlyB.length > 0) lines.push(`\n\u{1F535} Only in ${fileB.name}:\n ${onlyB.join(", ")}`);
if (different.length > 0) lines.push(`\n\u{1F7E1} Different values:\n${different.join("\n")}`);
lines.push(`\n\u2705 ${same} variables identical`);
return lines.join("\n");
}
function checkMissingVars(envFiles: EnvFile[]): string {
if (envFiles.length < 2) return "Need at least 2 .env files to check for missing variables.";
// Collect all keys across all files
const allKeys = new Set<string>();
for (const f of envFiles) {
for (const key of f.vars.keys()) {
allKeys.add(key);
}
}
const lines: string[] = [
`\u{1F50D} Missing Variables Check`,
`${'\u2500'.repeat(50)}`,
`Files: ${envFiles.map((f) => f.name).join(", ")}`,
`Total unique keys: ${allKeys.size}`,
"",
];
let missingCount = 0;
for (const key of Array.from(allKeys).sort()) {
const presentIn: string[] = [];
const missingFrom: string[] = [];
for (const f of envFiles) {
if (f.vars.has(key)) presentIn.push(f.name);
else missingFrom.push(f.name);
}
if (missingFrom.length > 0) {
lines.push(` \u26A0\uFE0F ${key}: missing from ${missingFrom.join(", ")}`);
missingCount++;
}
}
if (missingCount === 0) {
lines.push("\u2705 All variables present in all env files!");
} else {
lines.push(`\n\u{1F4CA} ${missingCount} variables missing across environments`);
}
return lines.join("\n");
}
function syncEnvFiles(source: EnvFile, target: EnvFile): { added: string[]; content: string } {
const added: string[] = [];
const targetContent = existsSync(target.path) ? readFileSync(target.path, "utf-8") : "";
let newContent = targetContent;
for (const [key, value] of source.vars) {
if (!target.vars.has(key)) {
added.push(key);
const appendLine = `\n# Synced from ${source.name}\n${key}=${value}`;
newContent += appendLine;
}
}
return { added, content: newContent };
}
pi.registerTool({
name: "env_manage",
label: "Environment Manager",
description: "Manage .env files: list, diff, check for missing vars, or sync between environments.",
parameters: Type.Object({
action: Type.String({ description: "Action: 'list', 'diff', 'check', 'sync'" }),
source: Type.Optional(Type.String({ description: "Source env file name for diff/sync" })),
target: Type.Optional(Type.String({ description: "Target env file name for diff/sync" })),
}),
execute: async (args, ctx) => {
const envFiles = findEnvFiles(ctx.cwd);
const action = args.action.toLowerCase();
switch (action) {
case "list": {
if (envFiles.length === 0) return { output: "No .env files found in project." };
const list = envFiles
.map((f) => ` ${f.name}: ${f.vars.size} variables`)
.join("\n");
return { output: `\u{1F4C1} Environment Files:\n${list}` };
}
case "diff": {
if (envFiles.length < 2) return { error: "Need at least 2 .env files to diff." };
const source = args.source ? envFiles.find((f) => f.name.includes(args.source!)) : envFiles[0];
const target = args.target ? envFiles.find((f) => f.name.includes(args.target!)) : envFiles[1];
if (!source || !target) return { error: "Could not find specified env files." };
return { output: diffEnvFiles(source, target) };
}
case "check": {
return { output: checkMissingVars(envFiles) };
}
case "sync": {
if (!args.source || !args.target) return { error: "Specify source and target env files." };
const source = envFiles.find((f) => f.name.includes(args.source!));
const target = envFiles.find((f) => f.name.includes(args.target!));
if (!source || !target) return { error: "Could not find specified env files." };
const { added, content } = syncEnvFiles(source, target);
if (added.length === 0) return { output: "\u2705 Target already has all source variables." };
writeFileSync(target.path, content, "utf-8");
return { output: `\u2705 Synced ${added.length} variables to ${target.name}:\n ${added.join(", ")}` };
}
default:
return { error: `Unknown action '${action}'. Use: list, diff, check, sync` };
}
},
});
pi.registerCommand("env", {
description: "Manage env files: /env [diff|check|sync <source> <target>]",
handler: async (args, ctx) => {
const parts = args.trim().split(/\s+/);
const subcommand = (parts[0] || "list").toLowerCase();
const envFiles = findEnvFiles(ctx.cwd);
switch (subcommand) {
case "list":
case "": {
if (envFiles.length === 0) {
ctx.ui.notify("\u{1F4C1} No .env files found in this project.", "info");
return;
}
const list = envFiles
.map((f) => {
const keys = Array.from(f.vars.keys()).slice(0, 5).join(", ");
const more = f.vars.size > 5 ? ` +${f.vars.size - 5} more` : "";
return ` \u{1F4C4} ${f.name} (${f.vars.size} vars): ${keys}${more}`;
})
.join("\n");
ctx.ui.notify(`\u{1F4C1} Environment Files:\n\n${list}`, "info");
break;
}
case "diff": {
if (envFiles.length < 2) {
ctx.ui.notify("Need at least 2 .env files to diff.", "warning");
return;
}
const source = parts[1] ? envFiles.find((f) => f.name.includes(parts[1])) : envFiles[0];
const target = parts[2] ? envFiles.find((f) => f.name.includes(parts[2])) : envFiles[1];
if (!source || !target) {
ctx.ui.notify("Could not find specified env files. Use /env to list them.", "warning");
return;
}
ctx.ui.notify(diffEnvFiles(source, target), "info");
break;
}
case "check": {
ctx.ui.notify(checkMissingVars(envFiles), "info");
break;
}
case "sync": {
const sourceName = parts[1];
const targetName = parts[2];
if (!sourceName || !targetName) {
ctx.ui.notify("Usage: /env sync <source> <target>\n\nExample: /env sync .env.development .env.production", "warning");
return;
}
const source = envFiles.find((f) => f.name.includes(sourceName));
const target = envFiles.find((f) => f.name.includes(targetName));
if (!source) {
ctx.ui.notify(`Could not find env file matching '${sourceName}'.`, "warning");
return;
}
if (!target) {
ctx.ui.notify(`Could not find env file matching '${targetName}'.`, "warning");
return;
}
const { added, content } = syncEnvFiles(source, target);
if (added.length === 0) {
ctx.ui.notify("\u2705 Target already has all source variables. Nothing to sync.", "info");
return;
}
writeFileSync(target.path, content, "utf-8");
ctx.ui.notify(`\u2705 Synced ${added.length} missing variables to ${target.name}:\n ${added.join(", ")}`, "info");
break;
}
default:
ctx.ui.notify(
`\u{1F4C1} Environment Manager\n${'\u2500'.repeat(30)}\n\n` +
"Commands:\n" +
" /env - List all .env files\n" +
" /env diff [a] [b] - Compare two env files\n" +
" /env check - Find missing vars across files\n" +
" /env sync <s> <t> - Sync missing vars from source to target",
"info",
);
}
},
});
}