295 lines
11 KiB
TypeScript
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",
|
|
);
|
|
}
|
|
},
|
|
});
|
|
}
|