From 6633033c53e1d823f8f330e13ffd171291171ed8 Mon Sep 17 00:00:00 2001 From: Maxwell Date: Fri, 3 Jul 2026 09:13:30 +0200 Subject: [PATCH] feat(env): add .env scaffolding and profile preset support --- src/cli/mod.ts | 323 ++++++++++++++++++- src/env/env_test.ts | 738 ++++++++++++++++++++++++++++++++++++++++++++ src/env/mod.ts | 451 +++++++++++++++++++++++++++ src/env/types.ts | 149 +++++++++ 4 files changed, 1649 insertions(+), 12 deletions(-) create mode 100644 src/env/env_test.ts create mode 100644 src/env/mod.ts create mode 100644 src/env/types.ts diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 4cb42d4..19878a2 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -6,7 +6,7 @@ import { ExitCode } from "../config/types.ts"; import { generateStacks } from "../compose/mod.ts"; import { discoverComposeFiles } from "../compose/discover.ts"; import type { ComposeData, GenerateOptions } from "../compose/mod.ts"; -import { join, resolve } from "@std/path"; +import { basename, dirname, join, resolve } from "@std/path"; import { ensureDir, exists } from "@std/fs"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; import { renderStack } from "../render/mod.ts"; @@ -22,6 +22,15 @@ import { dockerStackServices, dockerSwarmStatus, } from "../docker/mod.ts"; +import { + batchCreateEnvs, + diffEnvFiles, + discoverEnvExamples, + envDoctor, + getEnvStatusList, + materializeEnvFromProfile, +} from "../env/mod.ts"; +import type { EnvDiff } from "../env/types.ts"; let exitCode = 0; @@ -872,19 +881,309 @@ export function buildCli(): Command { }); // --- env (issue #14) --- - cli.command("env", "Manage .env files and profile env presets.") - .option("--list", "List discovered services and .env status.") - .option("--recreate", "Create missing .env files from .env.example.") + const envCmd = cli.command( + "env", + "Manage .env files and profile env presets.", + ); + + // env list + envCmd.command("list", "List discovered .env.example files and their status.") + .option("--profile ", "Use a specific profile for variant lookup.") + .option("--paths ", "Comma-separated list of service paths to limit listing.") + .option("--json", "Output machine-readable JSON.") + .option("--list", "Extended status listing (example, env, encrypted, profile variants).") + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const jsonOutput = options.json as boolean | undefined; + const extendedList = options.list as boolean | undefined; + const pathsOpt = options.paths as string | undefined; + const paths = pathsOpt + ? pathsOpt.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const cwd = Deno.cwd(); + + if (extendedList) { + const statusList = await getEnvStatusList(cwd, { profile, paths }); + if (jsonOutput) { + console.log(JSON.stringify(statusList, null, 2)); + } else { + if (statusList.length === 0) { + console.log("No .env files or examples found."); + return; + } + console.log( + `${"Service".padEnd(28)} ${"Example".padEnd(8)} ${"Env".padEnd(8)} ${ + "Enc".padEnd(8) + } ${"Profile".padEnd(12)} Path`, + ); + console.log( + `${"-".repeat(28)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${ + "-".repeat(12) + } ${"-".repeat(40)}`, + ); + for (const entry of statusList) { + const exIcon = entry.hasExample ? "\u2713" : "\u2717"; + const envIcon = entry.hasEnv ? "\u2713" : "\u2717"; + const encIcon = entry.hasEncrypted ? "\u2713" : "\u2717"; + const profLabel = entry.profile ?? "(default)"; + const pathLabel = entry.envPath ?? entry.examplePath ?? ""; + console.log( + `${entry.serviceName.padEnd(28)} ${exIcon.padEnd(8)} ${envIcon.padEnd(8)} ${ + encIcon.padEnd(8) + } ${profLabel.padEnd(12)} ${pathLabel}`, + ); + } + } + } else { + const examples = await discoverEnvExamples(cwd, { profile, paths }); + if (jsonOutput) { + console.log(JSON.stringify(examples, null, 2)); + } else { + if (examples.length === 0) { + console.log("No .env.example files found."); + return; + } + console.log(`${"Service".padEnd(30)} ${"Status".padEnd(12)} Path`); + console.log(`${"-".repeat(30)} ${"-".repeat(12)} ${"-".repeat(40)}`); + for (const ex of examples) { + const icon = ex.status === "present" + ? "\u2713" + : ex.status === "outdated" + ? "~" + : "\u2717"; + console.log( + `${ex.serviceName.padEnd(30)} ${(icon + " " + ex.status).padEnd(12)} ${ex.envPath}`, + ); + } + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } + }); + + // env create + envCmd.command("create", "Create .env files from .env.example templates.") + .arguments("[name:string]") + .option("--profile ", "Use a specific profile for variant lookup.") + .option("--paths ", "Comma-separated list of service paths to limit creation.") .option("--force", "Overwrite existing .env files.") - .option("--yes", "Skip confirmation.") .option("--dry-run", "Print planned changes without writing.") - .option("--paths ", "Comma-separated list of service paths.") - .option("--profile ", "Use a specific profile.") - .option("--from-profile ", "Materialize env from a profile preset.") - .option("--materialize", "Materialize profile preset env values.") - .action(() => { - console.error("env: not yet implemented (issue #14)"); - exitCode = 1; + .option("--json", "Output machine-readable JSON.") + .action(async (options: Record, name?: string) => { + try { + const profile = options.profile as string | undefined; + const force = options.force as boolean | undefined; + const dryRun = options.dryRun as boolean | undefined; + const jsonOutput = options.json as boolean | undefined; + const pathsOpt = options.paths as string | undefined; + const paths = pathsOpt + ? pathsOpt.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const cwd = Deno.cwd(); + + const result = await batchCreateEnvs(cwd, { + profile, + force, + dryRun, + serviceName: name, + paths, + }); + + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (dryRun) { + for (const c of result.created) console.log(`[dry-run] would create: ${c.path}`); + for (const s of result.skipped) { + console.log(`[dry-run] would skip: ${s.path} (${s.reason})`); + } + } else { + for (const c of result.created) console.log(`created: ${c.path}`); + for (const s of result.skipped) console.log(`skipped: ${s.path} (${s.reason})`); + } + for (const e of result.errors) console.error(`error: ${e.path}: ${e.message}`); + } + if (result.errors.length > 0) exitCode = ExitCode.DriftOrValidation; + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } + }); + + // env diff + envCmd.command("diff", "Show differences between .env.example and .env files.") + .arguments("[name:string]") + .option("--profile ", "Use a specific profile for variant lookup.") + .option("--paths ", "Comma-separated list of service paths to limit diff.") + .option("--json", "Output machine-readable JSON.") + .action(async (options: Record, name?: string) => { + try { + const profile = options.profile as string | undefined; + const jsonOutput = options.json as boolean | undefined; + const pathsOpt = options.paths as string | undefined; + const paths = pathsOpt + ? pathsOpt.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const cwd = Deno.cwd(); + + const examples = await discoverEnvExamples(cwd, { profile, paths }); + const filtered = name + ? examples.filter((e) => + e.serviceName === name || basename(dirname(e.examplePath)) === name + ) + : examples; + + if (filtered.length === 0) { + console.log( + jsonOutput ? "[]" : `No .env.example files found${name ? ` matching "${name}"` : ""}.`, + ); + return; + } + + const diffs: EnvDiff[] = []; + for (const ex of filtered) { + diffs.push(await diffEnvFiles(ex.examplePath, ex.envPath, ex.serviceName)); + } + + if (jsonOutput) { + console.log(JSON.stringify(diffs, null, 2)); + } else { + for (const diff of diffs) { + console.log(`\n=== ${diff.serviceName} ===`); + if (diff.onlyInExample.length > 0) { + console.log(" Missing from .env:"); + for (const k of diff.onlyInExample) console.log(` - ${k}`); + } + if (diff.onlyInEnv.length > 0) { + console.log(" Only in .env (not in example):"); + for (const k of diff.onlyInEnv) console.log(` + ${k}`); + } + if (diff.common.length > 0) console.log(` Common (${diff.common.length} keys)`); + if (diff.onlyInExample.length === 0 && diff.onlyInEnv.length === 0) { + console.log(" (no differences)"); + } + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } + }); + + // env materialize + envCmd.command("materialize", "Materialize profile preset env values into .env files.") + .option( + "--from-profile ", + "Profile from which to source values (required).", + { required: true }, + ) + .option( + "--paths ", + "Comma-separated list of service paths to limit materialization.", + ) + .option("--force", "Overwrite existing .env files.") + .option("--dry-run", "Print planned changes without writing.") + .option("--json", "Output machine-readable JSON.") + .action(async (options: Record) => { + try { + const fromProfile = options.fromProfile as string; + const force = options.force as boolean | undefined; + const dryRun = options.dryRun as boolean | undefined; + const jsonOutput = options.json as boolean | undefined; + const pathsOpt = options.paths as string | undefined; + const paths = pathsOpt + ? pathsOpt.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const cwd = Deno.cwd(); + + if (!fromProfile) { + console.error("error: --from-profile is required"); + exitCode = ExitCode.UserConfigError; + return; + } + + const result = await materializeEnvFromProfile(cwd, { + profile: fromProfile, + force, + dryRun, + paths, + }); + + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + } else { + const prefix = dryRun ? "[dry-run] would materialize" : "materialized"; + for (const m of result.materialized) { + console.log(`${prefix}: ${m.sourcePath} -> ${m.targetPath}`); + } + for (const s of result.skipped) { + console.log(`skipped: ${s.sourcePath} -> ${s.targetPath} (${s.reason})`); + } + for (const e of result.errors) { + console.error(`error: ${e.serviceName}: ${e.message}`); + } + } + + if (result.errors.length > 0) exitCode = ExitCode.DriftOrValidation; + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } + }); + + // env audit + envCmd.command("audit", "Check .env files for sensitive plaintext issues.") + .option("--paths ", "Comma-separated list of service paths to limit check.") + .option("--dry-run", "Report what would be checked without logging as errors.") + .option("--json", "Output machine-readable JSON.") + .option("--suggest", "Suggest commands to fix issues (default: true).") + .action(async (options: Record) => { + try { + const pathsOpt = options.paths as string | undefined; + const dryRun = options.dryRun as boolean | undefined; + const jsonOutput = options.json as boolean | undefined; + const suggest = options.suggest !== false; // default true + const paths = pathsOpt + ? pathsOpt.split(",").map((s: string) => s.trim()).filter(Boolean) + : undefined; + const cwd = Deno.cwd(); + + const result = await envDoctor(cwd, { paths, dryRun, suggest }); + + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (result.findings.length === 0) { + console.log("No .env files found. Nothing to check."); + return; + } + + for (const finding of result.findings) { + const icon = finding.severity === "warning" ? "\u26A0" : "\u2139"; + console.log(`${icon} ${finding.message}`); + } + + if (result.hasWarnings) { + console.log( + "\n\u26A0 Warnings found. Consider running:", + ); + console.log(" stackctl secrets encrypt (to encrypt plaintext .env files)"); + console.log(" stackctl secrets clean (to remove plaintext after encryption)"); + } else { + console.log("\nNo sensitive plaintext issues detected."); + } + } + + if (result.hasWarnings) { + exitCode = ExitCode.DriftOrValidation; + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + exitCode = ExitCode.UnexpectedError; + } }); // --- plan (issue #15) --- diff --git a/src/env/env_test.ts b/src/env/env_test.ts new file mode 100644 index 0000000..03d6550 --- /dev/null +++ b/src/env/env_test.ts @@ -0,0 +1,738 @@ +/** + * Tests for env scaffolding - Issue #14. + */ +import { assertEquals, assertNotEquals, assertRejects } from "@std/assert"; +import { exists } from "@std/fs"; +import { join } from "@std/path"; +import { + batchCreateEnvs, + createEnvFromExample, + diffEnvFiles, + discoverEnvExamples, + envDoctor, + getEnvStatusList, + materializeEnvFromProfile, +} from "./mod.ts"; + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-env-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + await Deno.writeTextFile(join(dir, name), content); +} + +async function readFile(dir: string, name: string): Promise { + return await Deno.readTextFile(join(dir, name)); +} + +// === discoverEnvExamples === + +Deno.test("discoverEnvExamples: finds .env.example at root level", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 1); + assertEquals(results[0].serviceName, "root"); + assertEquals(results[0].examplePath, join(tmp, ".env.example")); + assertEquals(results[0].envPath, join(tmp, ".env")); + assertEquals(results[0].status, "missing"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: finds .env.example in subdirectory", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example", "KEY=value\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 1); + assertEquals(results[0].serviceName, "svc-a"); + assertEquals(results[0].status, "missing"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: status present when .env matches", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\n"); + await writeFile(tmp, ".env", "FOO=bar\nBAZ=qux\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 1); + assertEquals(results[0].status, "present"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: status outdated when .env missing keys", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\nNEW=val\n"); + await writeFile(tmp, ".env", "FOO=bar\nBAZ=qux\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 1); + assertEquals(results[0].status, "outdated"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: profile support", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example.staging", "STAGING_KEY=val\n"); + const resultsDefault = await discoverEnvExamples(tmp); + assertEquals(resultsDefault.length, 0); + const resultsProfile = await discoverEnvExamples(tmp, { profile: "staging" }); + assertEquals(resultsProfile.length, 1); + assertEquals(resultsProfile[0].serviceName, "root"); + assertEquals(resultsProfile[0].examplePath, join(tmp, ".env.example.staging")); + assertEquals(resultsProfile[0].envPath, join(tmp, ".env.staging")); + assertEquals(resultsProfile[0].status, "missing"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: profile with existing .env.", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example.prod", "KEY=val\n"); + await writeFile(tmp, ".env.prod", "KEY=val\n"); + const results = await discoverEnvExamples(tmp, { profile: "prod" }); + assertEquals(results.length, 1); + assertEquals(results[0].status, "present"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: skips hidden directories", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, ".hidden")); + await writeFile(join(tmp, ".hidden"), ".env.example", "FOO=bar\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 0); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: skips node_modules", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "node_modules")); + await writeFile(join(tmp, "node_modules"), ".env.example", "FOO=bar\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 0); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: --paths filtering", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await Deno.mkdir(join(tmp, "svc-c")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + await writeFile(join(tmp, "svc-c"), ".env.example", "C=3\n"); + const results = await discoverEnvExamples(tmp, { paths: ["svc-a", "svc-b"] }); + assertEquals(results.length, 2); + const names = results.map((r) => r.serviceName).sort(); + assertEquals(names, ["svc-a", "svc-b"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: --paths filtering single path", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + const results = await discoverEnvExamples(tmp, { paths: ["svc-a"] }); + assertEquals(results.length, 1); + assertEquals(results[0].serviceName, "svc-a"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: --paths filtering with nested dirs", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "apps", "api"), { recursive: true }); + await Deno.mkdir(join(tmp, "apps", "worker"), { recursive: true }); + await Deno.mkdir(join(tmp, "libs", "shared"), { recursive: true }); + await writeFile(join(tmp, "apps", "api"), ".env.example", "API=1\n"); + await writeFile(join(tmp, "apps", "worker"), ".env.example", "WORKER=1\n"); + await writeFile(join(tmp, "libs", "shared"), ".env.example", "SHARED=1\n"); + const results = await discoverEnvExamples(tmp, { paths: ["apps"] }); + assertEquals(results.length, 2); + const names = results.map((r) => r.serviceName).sort(); + assertEquals(names, ["apps/api", "apps/worker"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: multiple services", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await Deno.mkdir(join(tmp, "nested", "svc-c"), { recursive: true }); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + await writeFile(join(tmp, "nested", "svc-c"), ".env.example", "C=3\n"); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 3); + const names = results.map((r) => r.serviceName).sort(); + assertEquals(names, ["nested/svc-c", "svc-a", "svc-b"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discoverEnvExamples: empty directory returns empty", async () => { + const tmp = await makeTempDir(); + const results = await discoverEnvExamples(tmp); + assertEquals(results.length, 0); + await Deno.remove(tmp, { recursive: true }); +}); + +// === createEnvFromExample === + +Deno.test("createEnvFromExample: creates .env from .env.example", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\n"); + const result = await createEnvFromExample(examplePath, envPath); + assertEquals(result.created, true); + assertEquals(result.path, envPath); + assertEquals(await readFile(tmp, ".env"), "FOO=bar\nBAZ=qux\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: throws if .env already exists", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + await writeFile(tmp, ".env", "EXISTING=yes\n"); + await assertRejects(() => createEnvFromExample(examplePath, envPath), Error, "already exists"); + assertEquals(await readFile(tmp, ".env"), "EXISTING=yes\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: force overwrites existing .env", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + await writeFile(tmp, ".env", "OLD=val\n"); + const result = await createEnvFromExample(examplePath, envPath, { force: true }); + assertEquals(result.created, true); + assertEquals(await readFile(tmp, ".env"), "FOO=bar\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: force creates backup before overwrite", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "NEW=content\n"); + await writeFile(tmp, ".env", "OLD=content\n"); + await createEnvFromExample(examplePath, envPath, { force: true }); + assertEquals(await readFile(tmp, ".env"), "NEW=content\n"); + const entries = Array.from(Deno.readDirSync(tmp)); + const bakFiles = entries.filter((e) => e.name.startsWith(".env.bak.")); + assertEquals(bakFiles.length, 1); + const bakContent = await Deno.readTextFile(join(tmp, bakFiles[0].name)); + assertEquals(bakContent, "OLD=content\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: dry run does not write", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + const result = await createEnvFromExample(examplePath, envPath, { dryRun: true }); + assertEquals(result.created, true); + assertEquals(result.path, envPath); + assertEquals(await exists(envPath), false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: dry run reports not created when exists", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + await writeFile(tmp, ".env", "EXISTING=yes\n"); + const result = await createEnvFromExample(examplePath, envPath, { dryRun: true }); + assertEquals(result.created, false); + assertEquals(result.path, envPath); + assertEquals(await readFile(tmp, ".env"), "EXISTING=yes\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: dry run + force reports would overwrite", async () => { + const tmp = await makeTempDir(); + const examplePath = join(tmp, ".env.example"); + const envPath = join(tmp, ".env"); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + await writeFile(tmp, ".env", "EXISTING=yes\n"); + const result = await createEnvFromExample(examplePath, envPath, { force: true, dryRun: true }); + assertEquals(result.created, true); + assertEquals(result.path, envPath); + assertEquals(await readFile(tmp, ".env"), "EXISTING=yes\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("createEnvFromExample: throws if example does not exist", async () => { + const tmp = await makeTempDir(); + await assertRejects( + () => createEnvFromExample(join(tmp, "nope.example"), join(tmp, ".env")), + Error, + "not found", + ); + await Deno.remove(tmp, { recursive: true }); +}); + +// === diffEnvFiles === + +Deno.test("diffEnvFiles: reports keys present in both", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\n"); + await writeFile(tmp, ".env", "FOO=bar\nBAZ=qux\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env"), "test-svc"); + assertEquals(diff.serviceName, "test-svc"); + assertEquals(diff.onlyInExample, []); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common.sort(), ["BAZ", "FOO"].sort()); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: reports keys only in example", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\nNEW=val\n"); + await writeFile(tmp, ".env", "FOO=bar\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample.sort(), ["BAZ", "NEW"].sort()); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common, ["FOO"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: reports keys only in env", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\n"); + await writeFile(tmp, ".env", "FOO=bar\nEXTRA=val\nCUSTOM=yes\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample, []); + assertEquals(diff.onlyInEnv.sort(), ["CUSTOM", "EXTRA"].sort()); + assertEquals(diff.common, ["FOO"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: handles missing env file gracefully", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "FOO=bar\nBAZ=qux\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample.sort(), ["BAZ", "FOO"].sort()); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common, []); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: handles missing example file gracefully", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env", "FOO=bar\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample, []); + assertEquals(diff.onlyInEnv, ["FOO"]); + assertEquals(diff.common, []); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: empty files produce empty diff", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", ""); + await writeFile(tmp, ".env", ""); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env"), "empty"); + assertEquals(diff.serviceName, "empty"); + assertEquals(diff.onlyInExample, []); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common, []); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: handles comment lines", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "# comment\nFOO=bar\n# another\n"); + await writeFile(tmp, ".env", "FOO=bar\n# only comment\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample, []); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common, ["FOO"]); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("diffEnvFiles: handles export prefix", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "export FOO=bar\nexport BAZ=qux\n"); + await writeFile(tmp, ".env", "FOO=bar\n"); + const diff = await diffEnvFiles(join(tmp, ".env.example"), join(tmp, ".env")); + assertEquals(diff.onlyInExample, ["BAZ"]); + assertEquals(diff.onlyInEnv, []); + assertEquals(diff.common, ["FOO"]); + await Deno.remove(tmp, { recursive: true }); +}); + +// === batchCreateEnvs === + +Deno.test("batchCreateEnvs: creates multiple env files", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + const result = await batchCreateEnvs(tmp); + assertEquals(result.created.length, 2); + assertEquals(result.skipped.length, 0); + assertEquals(result.errors.length, 0); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "A=1\n"); + assertEquals(await readFile(join(tmp, "svc-b"), ".env"), "B=2\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: skips existing .env files", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-a"), ".env", "EXISTING=yes\n"); + const result = await batchCreateEnvs(tmp); + assertEquals(result.created.length, 0); + assertEquals(result.skipped.length, 1); + assertEquals(result.skipped[0].path, join(tmp, "svc-a", ".env")); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "EXISTING=yes\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: force overwrites existing", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-a"), ".env", "OLD=val\n"); + const result = await batchCreateEnvs(tmp, { force: true }); + assertEquals(result.created.length, 1); + assertEquals(result.skipped.length, 0); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "A=1\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: force creates backup before overwrite", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example", "NEW=val\n"); + await writeFile(join(tmp, "svc-a"), ".env", "OLD=val\n"); + const result = await batchCreateEnvs(tmp, { force: true }); + assertEquals(result.created.length, 1); + assertEquals(result.errors.length, 0); + const entries = Array.from(Deno.readDirSync(join(tmp, "svc-a"))); + const bakFiles = entries.filter((e) => e.name.startsWith(".env.bak.")); + assertEquals(bakFiles.length, 1); + const bakContent = await Deno.readTextFile(join(tmp, "svc-a", bakFiles[0].name)); + assertEquals(bakContent, "OLD=val\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: dry run does not write", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + const result = await batchCreateEnvs(tmp, { dryRun: true }); + assertEquals(result.created.length, 1); + assertEquals(result.created[0].created, true); + assertEquals(await exists(join(tmp, "svc-a", ".env")), false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: filter by service name", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + const result = await batchCreateEnvs(tmp, { serviceName: "svc-a" }); + assertEquals(result.created.length, 1); + assertEquals(result.created[0].path, join(tmp, "svc-a", ".env")); + assertEquals(await exists(join(tmp, "svc-a", ".env")), true); + assertEquals(await exists(join(tmp, "svc-b", ".env")), false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: filter by --paths", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await Deno.mkdir(join(tmp, "svc-c")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + await writeFile(join(tmp, "svc-c"), ".env.example", "C=3\n"); + const result = await batchCreateEnvs(tmp, { paths: ["svc-a", "svc-c"] }); + assertEquals(result.created.length, 2); + assertEquals(await exists(join(tmp, "svc-a", ".env")), true); + assertEquals(await exists(join(tmp, "svc-b", ".env")), false); + assertEquals(await exists(join(tmp, "svc-c", ".env")), true); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("batchCreateEnvs: no examples found returns empty", async () => { + const tmp = await makeTempDir(); + const result = await batchCreateEnvs(tmp); + assertEquals(result.created.length, 0); + assertEquals(result.skipped.length, 0); + assertEquals(result.errors.length, 0); + await Deno.remove(tmp, { recursive: true }); +}); + +// === materializeEnvFromProfile === + +Deno.test("materializeEnvFromProfile: copies profile env to .env", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile( + join(tmp, "svc-a"), + ".env.example.staging", + "HOST=staging.example.com\nPORT=8080\n", + ); + const result = await materializeEnvFromProfile(tmp, { profile: "staging" }); + assertEquals(result.materialized.length, 1); + assertEquals(result.errors.length, 0); + assertEquals(result.materialized[0].serviceName, "svc-a"); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "HOST=staging.example.com\nPORT=8080\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: skips existing .env without force", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "NEW=val\n"); + await writeFile(join(tmp, "svc-a"), ".env", "OLD=val\n"); + const result = await materializeEnvFromProfile(tmp, { profile: "staging" }); + assertEquals(result.materialized.length, 0); + assertEquals(result.skipped.length, 1); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "OLD=val\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: force overwrites existing", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "NEW=val\n"); + await writeFile(join(tmp, "svc-a"), ".env", "OLD=val\n"); + const result = await materializeEnvFromProfile(tmp, { profile: "staging", force: true }); + assertEquals(result.materialized.length, 1); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "NEW=val\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: force creates backup", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "NEW=val\n"); + await writeFile(join(tmp, "svc-a"), ".env", "OLD=val\n"); + await materializeEnvFromProfile(tmp, { profile: "staging", force: true }); + const entries = Array.from(Deno.readDirSync(join(tmp, "svc-a"))); + const bakFiles = entries.filter((e) => e.name.startsWith(".env.bak.")); + assertEquals(bakFiles.length, 1); + const bakContent = await Deno.readTextFile(join(tmp, "svc-a", bakFiles[0].name)); + assertEquals(bakContent, "OLD=val\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: dry run does not write", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "HOST=example.com\n"); + const result = await materializeEnvFromProfile(tmp, { profile: "staging", dryRun: true }); + assertEquals(result.materialized.length, 1); + assertEquals(await exists(join(tmp, "svc-a", ".env")), false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: --paths filtering", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example.staging", "B=2\n"); + const result = await materializeEnvFromProfile(tmp, { profile: "staging", paths: ["svc-a"] }); + assertEquals(result.materialized.length, 1); + assertEquals(await exists(join(tmp, "svc-b", ".env")), false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: throws without profile", async () => { + const tmp = await makeTempDir(); + await assertRejects( + () => materializeEnvFromProfile(tmp, { profile: "" }), + Error, + ); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("materializeEnvFromProfile: handles multiple services", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await Deno.mkdir(join(tmp, "nested", "svc-c"), { recursive: true }); + await writeFile(join(tmp, "svc-a"), ".env.example.staging", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example.staging", "B=2\n"); + await writeFile(join(tmp, "nested", "svc-c"), ".env.example.staging", "C=3\n"); + const result = await materializeEnvFromProfile(tmp, { profile: "staging" }); + assertEquals(result.materialized.length, 3); + assertEquals(await readFile(join(tmp, "svc-a"), ".env"), "A=1\n"); + assertEquals(await readFile(join(tmp, "svc-b"), ".env"), "B=2\n"); + assertEquals(await readFile(join(tmp, "nested", "svc-c"), ".env"), "C=3\n"); + await Deno.remove(tmp, { recursive: true }); +}); + +// === envDoctor === + +Deno.test("envDoctor: warns about plaintext .env with encrypted counterpart", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env", "SECRET=plaintext\n"); + await writeFile(join(tmp, "svc-a"), ".env.enc", "encrypted-content\n"); + const result = await envDoctor(tmp); + assertEquals(result.hasWarnings, true); + const warnings = result.findings.filter((f) => f.severity === "warning"); + assertEquals(warnings.length, 1); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: info for .env without encrypted counterpart", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await writeFile(join(tmp, "svc-a"), ".env", "SECRET=plaintext\n"); + const result = await envDoctor(tmp); + const infos = result.findings.filter((f) => f.severity === "info"); + assertEquals(infos.length, 1); + assertEquals(result.hasWarnings, false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: skips skipped dirs", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "node_modules", "pkg"), { recursive: true }); + await writeFile(join(tmp, "node_modules", "pkg"), ".env", "SKIP=me\n"); + await writeFile(join(tmp, "node_modules", "pkg"), ".env.enc", "enc\n"); + const result = await envDoctor(tmp); + assertEquals(result.findings.length, 0); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: --paths filtering", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env", "SECRET_A=plain\n"); + await writeFile(join(tmp, "svc-a"), ".env.enc", "enc-a\n"); + await writeFile(join(tmp, "svc-b"), ".env", "SECRET_B=plain\n"); + await writeFile(join(tmp, "svc-b"), ".env.enc", "enc-b\n"); + const result = await envDoctor(tmp, { paths: ["svc-a"] }); + assertEquals(result.findings.length, 1); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: dry run prefix", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env", "SECRET=val\n"); + await writeFile(tmp, ".env.enc", "encrypted\n"); + const result = await envDoctor(tmp, { dryRun: true }); + assertEquals(result.hasWarnings, true); + const warnings = result.findings.filter((f) => f.severity === "warning"); + assertEquals(warnings.length, 1); + assertNotEquals(warnings[0].message.indexOf("[dry-run]"), -1); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: no .env files returns empty", async () => { + const tmp = await makeTempDir(); + const result = await envDoctor(tmp); + assertEquals(result.findings.length, 0); + assertEquals(result.hasWarnings, false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("envDoctor: multiple .env files", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await Deno.mkdir(join(tmp, "svc-c")); + await writeFile(join(tmp, "svc-a"), ".env", "A=1\n"); + await writeFile(join(tmp, "svc-a"), ".env.enc", "enc-a\n"); + await writeFile(join(tmp, "svc-b"), ".env", "B=2\n"); + await writeFile(join(tmp, "svc-c"), ".env", "C=3\n"); + await writeFile(join(tmp, "svc-c"), ".env.enc", "enc-c\n"); + const result = await envDoctor(tmp); + const warnings = result.findings.filter((f) => f.severity === "warning"); + const infos = result.findings.filter((f) => f.severity === "info"); + assertEquals(warnings.length, 2); + assertEquals(infos.length, 1); + assertEquals(result.hasWarnings, true); + await Deno.remove(tmp, { recursive: true }); +}); + +// === getEnvStatusList === + +Deno.test("getEnvStatusList: shows services with .env.example only", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "KEY=val\n"); + const entries = await getEnvStatusList(tmp); + assertEquals(entries.length, 1); + assertEquals(entries[0].serviceName, "root"); + assertEquals(entries[0].hasExample, true); + assertEquals(entries[0].hasEnv, false); + assertEquals(entries[0].hasEncrypted, false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("getEnvStatusList: shows services with active .env", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "KEY=val\n"); + await writeFile(tmp, ".env", "KEY=val\n"); + const entries = await getEnvStatusList(tmp); + assertEquals(entries.length, 1); + assertEquals(entries[0].hasExample, true); + assertEquals(entries[0].hasEnv, true); + assertEquals(entries[0].hasEncrypted, false); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("getEnvStatusList: shows services with encrypted .env.enc", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "KEY=val\n"); + await writeFile(tmp, ".env", "KEY=val\n"); + await writeFile(tmp, ".env.enc", "encrypted-stuff\n"); + const entries = await getEnvStatusList(tmp); + assertEquals(entries.length, 1); + assertEquals(entries[0].hasEncrypted, true); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("getEnvStatusList: shows profile-specific variants", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example.staging", "STAGING=val\n"); + await writeFile(tmp, ".env.staging", "STAGING=val\n"); + const entries = await getEnvStatusList(tmp); + assertEquals(entries.length, 1); + assertEquals(entries[0].serviceName, "root"); + assertEquals(entries[0].profile, "staging"); + assertEquals(entries[0].hasExample, true); + assertEquals(entries[0].hasEnv, true); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("getEnvStatusList: profile-specific filtering", async () => { + const tmp = await makeTempDir(); + await writeFile(tmp, ".env.example", "DEFAULT=val\n"); + await writeFile(tmp, ".env.example.staging", "STAGING=val\n"); + const entries = await getEnvStatusList(tmp, { profile: "staging" }); + assertEquals(entries.length, 1); + assertEquals(entries[0].profile, "staging"); + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("getEnvStatusList: --paths filtering", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(join(tmp, "svc-a")); + await Deno.mkdir(join(tmp, "svc-b")); + await writeFile(join(tmp, "svc-a"), ".env.example", "A=1\n"); + await writeFile(join(tmp, "svc-b"), ".env.example", "B=2\n"); + const entries = await getEnvStatusList(tmp, { paths: ["svc-a"] }); + assertEquals(entries.length, 1); + assertEquals(entries[0].serviceName, "svc-a"); + await Deno.remove(tmp, { recursive: true }); +}); diff --git a/src/env/mod.ts b/src/env/mod.ts new file mode 100644 index 0000000..6126b9a --- /dev/null +++ b/src/env/mod.ts @@ -0,0 +1,451 @@ +/** + * Env module - .env scaffolding and profile preset support. + * + * Issue #14: feat(env): add .env scaffolding and profile preset support + */ +import { exists, walk } from "@std/fs"; +import { basename, dirname, join, relative } from "@std/path"; +import type { + BatchCreateResult, + CreateOptions, + CreateResult, + DiscoverOptions, + DoctorFinding, + DoctorOptions, + DoctorResult, + EnvDiff, + EnvExample, + EnvStatusEntry, + MaterializeOptions, + MaterializeResult, + MaterializeResultItem, +} from "./types.ts"; + +const DEFAULT_SKIP_DIRS = new Set([ + "node_modules", + ".git", + "stacks", + ".rendered", + "dist", + "tools", + "__pycache__", +]); + +/** Parse a .env-style file and return only the keys (no values). */ +async function parseEnvKeys(path: string): Promise { + const raw: string = await Deno.readTextFile(path); + const keys: string[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + let effective = trimmed; + if (effective.startsWith("export ")) effective = effective.slice(7).trim(); + const eqIdx = effective.indexOf("="); + if (eqIdx === -1) continue; + const key = effective.slice(0, eqIdx).trim(); + if (key.length > 0) keys.push(key); + } + return keys; +} + +/** Derive a human-readable service name from an .env.example file path. */ +function deriveServiceName(examplePath: string, projectDir: string): string { + const dir = dirname(examplePath); + if (dir === projectDir) return "root"; + const rel = dir.startsWith(projectDir) ? dir.slice(projectDir.length + 1) : dir; + return rel || basename(dir); +} + +/** + * Walk the project directory looking for .env.example files. + * Profile support: when `profile` is provided, looks for + * `.env.example.` and `.env.` variants. + */ +export async function discoverEnvExamples( + projectDir: string, + options?: DiscoverOptions, +): Promise { + const profile = options?.profile; + const paths = options?.paths ?? []; + const results: EnvExample[] = []; + const exampleSuffix = profile ? `.env.example.${profile}` : ".env.example"; + const envSuffix = profile ? `.env.${profile}` : ".env"; + + for await ( + const entry of walk(projectDir, { includeDirs: false, includeFiles: true }) + ) { + const name = entry.path.split("/").pop()!; + if (name !== exampleSuffix) continue; + const parentDir = dirname(entry.path); + if (hasSkipAncestor(parentDir, projectDir, DEFAULT_SKIP_DIRS)) continue; + if (isInHiddenDir(parentDir, projectDir)) continue; + + if (paths.length > 0 && !matchesPaths(entry.path, projectDir, paths)) continue; + + const examplePath = entry.path; + const envDir = dirname(examplePath); + const envPath = join(envDir, envSuffix); + const serviceName = deriveServiceName(examplePath, projectDir); + + let status: EnvExample["status"]; + const envExists = await exists(envPath); + if (!envExists) { + status = "missing"; + } else { + try { + const exampleKeys = await parseEnvKeys(examplePath); + const envKeys = await parseEnvKeys(envPath); + const missingKeys = exampleKeys.filter((k) => !envKeys.includes(k)); + status = missingKeys.length > 0 ? "outdated" : "present"; + } catch { + status = "present"; + } + } + results.push({ serviceName, examplePath, envPath, status }); + } + return results; +} + +function hasSkipAncestor(dir: string, projectDir: string, skipDirs: Set): boolean { + const rel = dir.startsWith(projectDir) ? dir.slice(projectDir.length + 1) : dir; + const parts = rel.split("/").filter(Boolean); + for (const part of parts) if (skipDirs.has(part)) return true; + return false; +} + +function isInHiddenDir(dir: string, projectDir: string): boolean { + if (dir === projectDir) return false; + const rel = dir.startsWith(projectDir) ? dir.slice(projectDir.length + 1) : dir; + const parts = rel.split("/").filter(Boolean); + for (const part of parts) if (part.startsWith(".")) return true; + return false; +} + +/** Copy .env.example to .env. Throws if .env exists and force is not set. */ +export async function createEnvFromExample( + examplePath: string, + envPath: string, + options?: CreateOptions, +): Promise { + const force = options?.force ?? false; + const dryRun = options?.dryRun ?? false; + + const exampleExists = await exists(examplePath); + if (!exampleExists) throw new Error(`Example file not found: ${examplePath}`); + + const envExists = await exists(envPath); + if (envExists && !force) { + if (dryRun) return { created: false, path: envPath }; + throw new Error(`Env file already exists: ${envPath}. Use --force to overwrite.`); + } + + if (dryRun) return { created: true, path: envPath }; + + if (envExists && force) { + await backupEnvBeforeOverwrite(envPath); + } + + const contents = await Deno.readTextFile(examplePath); + await Deno.writeTextFile(envPath, contents); + return { created: true, path: envPath }; +} + +/** Compare keys between .env.example and .env files. */ +export async function diffEnvFiles( + examplePath: string, + envPath: string, + serviceName?: string, +): Promise { + const name = serviceName ?? basename(dirname(examplePath)); + let exampleKeys: string[] = []; + let envKeys: string[] = []; + + const exampleExists = await exists(examplePath); + if (exampleExists) { + try { + exampleKeys = await parseEnvKeys(examplePath); + } catch (err: unknown) { + throw new Error( + `Failed to parse example file ${examplePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + const envExists = await exists(envPath); + if (envExists) { + try { + envKeys = await parseEnvKeys(envPath); + } catch (err: unknown) { + throw new Error( + `Failed to parse env file ${envPath}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + const exampleSet = new Set(exampleKeys); + const envSet = new Set(envKeys); + const onlyInExample = exampleKeys.filter((k) => !envSet.has(k)); + const onlyInEnv = envKeys.filter((k) => !exampleSet.has(k)); + const common = exampleKeys.filter((k) => envSet.has(k)); + return { serviceName: name, onlyInExample, onlyInEnv, common }; +} + +/** Discover .env.example files and create .env for each. */ +export async function batchCreateEnvs( + projectDir: string, + options?: DiscoverOptions & CreateOptions & { serviceName?: string }, +): Promise { + const profile = options?.profile; + const force = options?.force ?? false; + const dryRun = options?.dryRun ?? false; + const serviceName = options?.serviceName; + const paths = options?.paths; + + const examples = await discoverEnvExamples(projectDir, { profile, paths }); + const filtered = serviceName + ? examples.filter((e) => + e.serviceName === serviceName || basename(dirname(e.examplePath)) === serviceName + ) + : examples; + + const result: BatchCreateResult = { created: [], skipped: [], errors: [] }; + for (const ex of filtered) { + try { + const cr = await createEnvFromExample(ex.examplePath, ex.envPath, { force, dryRun }); + if (cr.created) result.created.push(cr); + else {result.skipped.push({ + path: cr.path, + reason: "Env file already exists (use --force to overwrite)", + });} + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes("already exists")) { + result.skipped.push({ path: ex.envPath, reason: message }); + } else result.errors.push({ path: ex.envPath, message }); + } + } + return result; +} + +function matchesPaths(filePath: string, projectDir: string, paths: string[]): boolean { + if (paths.length === 0) return true; + const relPath = relative(projectDir, filePath); + for (const p of paths) { + const normalized = p.replace(/\/$/, ""); + if (relPath === normalized) return true; + if (relPath.startsWith(normalized + "/")) return true; + } + return false; +} + +export async function getEnvStatusList( + projectDir: string, + o?: { profile?: string; paths?: string[] }, +): Promise { + const profile = o?.profile; + const paths = o?.paths ?? []; + const entries: EnvStatusEntry[] = []; + const seen = new Set(); + + const examples = await discoverEnvExamples(projectDir, { profile, paths }); + + for (const ex of examples) { + const envDir = dirname(ex.examplePath); + const encryptedPath = join(envDir, ".env.enc"); + const hasEncrypted = await exists(encryptedPath); + const hasEnv = ex.status !== "missing"; + + entries.push({ + serviceName: ex.serviceName, + examplePath: ex.examplePath, + envPath: ex.envPath, + encryptedPath: hasEncrypted ? encryptedPath : undefined, + profile: profile, + hasExample: true, + hasEnv, + hasEncrypted, + }); + seen.add(ex.serviceName); + } + + if (!profile) { + for await ( + const entry of walk(projectDir, { includeDirs: false, includeFiles: true }) + ) { + const name = entry.path.split("/").pop()!; + const match = name.match(/^\.env\.example\.(.+)$/); + if (!match) continue; + + const mp = match[1]; + const parentDir = dirname(entry.path); + if (hasSkipAncestor(parentDir, projectDir, DEFAULT_SKIP_DIRS)) continue; + if (isInHiddenDir(parentDir, projectDir)) continue; + if (paths.length > 0 && !matchesPaths(entry.path, projectDir, paths)) continue; + + const svcName = deriveServiceName(entry.path, projectDir); + const envDir = dirname(entry.path); + const envPath = join(envDir, ".env." + mp); + const encryptedPath = join(envDir, ".env.enc"); + const hasEnv = await exists(envPath); + const hasEncrypted = await exists(encryptedPath); + + const key = svcName + ":" + mp; + if (seen.has(key)) continue; + seen.add(key); + + entries.push({ + serviceName: svcName, + examplePath: entry.path, + envPath, + encryptedPath: hasEncrypted ? encryptedPath : undefined, + profile: mp, + hasExample: true, + hasEnv, + hasEncrypted, + }); + } + } + + entries.sort((a, b) => { + const sn = a.serviceName.localeCompare(b.serviceName); + if (sn !== 0) return sn; + return (a.profile ?? "").localeCompare(b.profile ?? ""); + }); + + return entries; +} + +async function backupEnvBeforeOverwrite(envPath: string): Promise { + if (!(await exists(envPath))) return; + const ts = new Date().toISOString().replace(/[:.]/g, "-"); + const bakPath = envPath + ".bak." + ts; + const contents = await Deno.readTextFile(envPath); + await Deno.writeTextFile(bakPath, contents); +} + +export async function materializeEnvFromProfile( + projectDir: string, + options: MaterializeOptions, +): Promise { + const { profile, force = false, dryRun = false, paths = [] } = options; + if (!profile) throw new Error("--from-profile (or --profile) is required for materialize"); + + const result: MaterializeResult = { materialized: [], skipped: [], errors: [] }; + const exampleSuffix = ".env.example." + profile; + const targetSuffix = ".env"; + + for await ( + const entry of walk(projectDir, { includeDirs: false, includeFiles: true }) + ) { + const name = entry.path.split("/").pop()!; + if (name !== exampleSuffix) continue; + + const parentDir = dirname(entry.path); + if (hasSkipAncestor(parentDir, projectDir, DEFAULT_SKIP_DIRS)) continue; + if (isInHiddenDir(parentDir, projectDir)) continue; + if (paths.length > 0 && !matchesPaths(entry.path, projectDir, paths)) continue; + + const examplePath = entry.path; + const envDir = dirname(examplePath); + const targetPath = join(envDir, targetSuffix); + const svcName = deriveServiceName(examplePath, projectDir); + + const item: MaterializeResultItem = { + serviceName: svcName, + sourcePath: examplePath, + targetPath, + written: false, + }; + + try { + const targetExists = await exists(targetPath); + + if (targetExists && !force) { + item.reason = "Target .env already exists (use --force to overwrite)"; + result.skipped.push(item); + continue; + } + + if (dryRun) { + item.written = true; + result.materialized.push(item); + continue; + } + + if (targetExists && force) { + await backupEnvBeforeOverwrite(targetPath); + } + + const contents = await Deno.readTextFile(examplePath); + await Deno.writeTextFile(targetPath, contents); + item.written = true; + result.materialized.push(item); + } catch (err: unknown) { + result.errors.push({ + serviceName: svcName, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + return result; +} + +export async function envDoctor( + projectDir: string, + options?: DoctorOptions, +): Promise { + const paths = options?.paths ?? []; + const dryRun = options?.dryRun ?? false; + const suggest = options?.suggest ?? true; + const findings: DoctorFinding[] = []; + + for await ( + const entry of walk(projectDir, { includeDirs: false, includeFiles: true }) + ) { + const name = entry.path.split("/").pop()!; + if (name !== ".env") continue; + const parentDir = dirname(entry.path); + if (hasSkipAncestor(parentDir, projectDir, DEFAULT_SKIP_DIRS)) continue; + if (isInHiddenDir(parentDir, projectDir)) continue; + if (paths.length > 0 && !matchesPaths(entry.path, projectDir, paths)) continue; + + const envPath = entry.path; + const encryptedPath = join(dirname(envPath), ".env.enc"); + const hasEncrypted = await exists(encryptedPath); + const relEnv = relative(projectDir, envPath); + + if (hasEncrypted) { + const parts = ["Plaintext .env file has encrypted counterpart: " + relEnv]; + if (suggest) { + parts.push(" Suggest: stackctl secrets encrypt " + relEnv); + parts.push(" or stackctl secrets clean to remove plaintext"); + } + if (dryRun) { + parts.unshift("[dry-run] Would warn:"); + } + findings.push({ + envPath, + encryptedPath, + severity: "warning", + message: parts.join("\n"), + }); + } else { + const msg = dryRun + ? "[dry-run] Would note: Plaintext .env file (no encrypted counterpart): " + relEnv + : "Plaintext .env file (no encrypted counterpart): " + relEnv; + findings.push({ + envPath, + severity: "info", + message: msg, + }); + } + } + + return { + findings, + hasWarnings: findings.some((f) => f.severity === "warning"), + }; +} diff --git a/src/env/types.ts b/src/env/types.ts new file mode 100644 index 0000000..0e6738f --- /dev/null +++ b/src/env/types.ts @@ -0,0 +1,149 @@ +/** + * Env module types - Issue #14 + * + * Types for .env scaffolding and profile preset support. + */ + +/** A discovered .env.example file with its status. */ +export interface EnvExample { + /** Human-readable service/directory name derived from path. */ + serviceName: string; + /** Absolute path to the .env.example file. */ + examplePath: string; + /** Absolute path to the corresponding .env file. */ + envPath: string; + /** Whether .env is present, missing, or outdated relative to .env.example. */ + status: "present" | "missing" | "outdated"; +} + +/** Result of creating a .env file from a .env.example. */ +export interface CreateResult { + /** Whether the .env file was actually created. */ + created: boolean; + /** Absolute path to the .env file. */ + path: string; +} + +/** Diff between two .env-style files (keys only). */ +export interface EnvDiff { + /** Human-readable service/directory name. */ + serviceName: string; + /** Keys present in .env.example but missing from .env. */ + onlyInExample: string[]; + /** Keys present in .env but missing from .env.example. */ + onlyInEnv: string[]; + /** Keys present in both files. */ + common: string[]; +} + +/** Options for discovering .env.example files. */ +export interface DiscoverOptions { + /** Optional profile name for variant lookup. */ + profile?: string; + /** Optional comma-separated service paths to limit scope. */ + paths?: string[]; +} + +/** Options for creating .env from .env.example. */ +export interface CreateOptions { + /** Overwrite existing .env file. */ + force?: boolean; + /** Dry run: report what would happen without writing. */ + dryRun?: boolean; +} + +/** Results of a batch create operation. */ +export interface BatchCreateResult { + /** Successfully created items. */ + created: CreateResult[]; + /** Items that were skipped because .env already exists. */ + skipped: { path: string; reason: string }[]; + /** Errors encountered during creation. */ + errors: { path: string; message: string }[]; +} + +/** A single entry in the env status listing. */ +export interface EnvStatusEntry { + /** Human-readable service/directory name. */ + serviceName: string; + /** Absolute path to the .env.example variant. */ + examplePath: string; + /** Absolute path to the corresponding .env. */ + envPath: string; + /** Absolute path to the encrypted .env.enc, if present. */ + encryptedPath?: string; + /** Profile variant name, if applicable. */ + profile?: string; + /** Whether the example file exists. */ + hasExample: boolean; + /** Whether the .env file exists. */ + hasEnv: boolean; + /** Whether the encrypted .env.enc file exists. */ + hasEncrypted: boolean; +} + +/** Options for envDoctor. */ +export interface DoctorOptions { + /** Comma-separated service paths to limit scope. */ + paths?: string[]; + /** Dry run: report without applying changes. */ + dryRun?: boolean; + /** Whether to suggest remediation commands. */ + suggest?: boolean; +} + +/** A single finding from envDoctor. */ +export interface DoctorFinding { + /** Absolute path to the .env file. */ + envPath: string; + /** Absolute path to the encrypted .env.enc, if present. */ + encryptedPath?: string; + /** Severity level. */ + severity: "warning" | "info"; + /** Human-readable message. */ + message: string; +} + +/** Results of envDoctor. */ +export interface DoctorResult { + /** All findings from the audit. */ + findings: DoctorFinding[]; + /** True if any warnings were found. */ + hasWarnings: boolean; +} + +/** Options for materializeEnvFromProfile. */ +export interface MaterializeOptions { + /** Profile name to source values from (required). */ + profile: string; + /** Overwrite existing .env files. */ + force?: boolean; + /** Dry run: report without writing. */ + dryRun?: boolean; + /** Comma-separated service paths to limit scope. */ + paths?: string[]; +} + +/** Result of materializing a single profile env. */ +export interface MaterializeResultItem { + /** Human-readable service name. */ + serviceName: string; + /** Path to the source profile env example. */ + sourcePath: string; + /** Path to the target .env file. */ + targetPath: string; + /** Whether the target file was written. */ + written: boolean; + /** Reason for skipping, if applicable. */ + reason?: string; +} + +/** Results of materializeEnvFromProfile. */ +export interface MaterializeResult { + /** Successfully materialized items. */ + materialized: MaterializeResultItem[]; + /** Items that were skipped. */ + skipped: MaterializeResultItem[]; + /** Errors encountered. */ + errors: { serviceName: string; message: string }[]; +}