diff --git a/deno.lock b/deno.lock index 160f1c9..33891bb 100644 --- a/deno.lock +++ b/deno.lock @@ -7,8 +7,13 @@ "jsr:@cliffy/table@1.2.1": "1.2.1", "jsr:@std/assert@^1.0.18": "1.0.19", "jsr:@std/fmt@^1.0.10": "1.0.10", + "jsr:@std/fs@1": "1.0.24", "jsr:@std/internal@^1.0.12": "1.0.13", - "jsr:@std/text@^1.0.19": "1.0.19" + "jsr:@std/internal@^1.0.14": "1.0.14", + "jsr:@std/path@^1.1.4": "1.1.5", + "jsr:@std/path@^1.1.5": "1.1.5", + "jsr:@std/text@^1.0.19": "1.0.19", + "jsr:@std/yaml@^1.1.1": "1.1.1" }, "jsr": { "@cliffy/command@1.2.1": { @@ -40,17 +45,39 @@ "@std/assert@1.0.19": { "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.12" ] }, "@std/fmt@1.0.10": { "integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5" }, + "@std/fs@1.0.24": { + "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", + "dependencies": [ + "jsr:@std/internal@^1.0.14", + "jsr:@std/path@^1.1.5" + ] + }, "@std/internal@1.0.13": { "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5" + }, + "@std/path@1.1.5": { + "integrity": "ccea00982ea28c36becaf6e62f855406c76a8c32d462f66f415bbb7d83a271bc", + "dependencies": [ + "jsr:@std/internal@^1.0.14" + ] + }, "@std/text@1.0.19": { "integrity": "003a0e032d360e8c3a4e0410fb792c77a66bd6553fee9d60c6ec1bce30d29223" + }, + "@std/yaml@1.1.1": { + "integrity": "a57665ecf3d17b926380593a56625d8a10cc7281802f1e993b5ebc94a48e71f8" } }, "workspace": { diff --git a/src/cli/mod.ts b/src/cli/mod.ts index 2b815fe..162db4f 100644 --- a/src/cli/mod.ts +++ b/src/cli/mod.ts @@ -1,5 +1,12 @@ import { Command } from "@cliffy/command"; import { VERSION } from "../version.ts"; +import { initConfig } from "../config/mod.ts"; +import { resolveConfig } from "../config/mod.ts"; +import { ExitCode } from "../config/types.ts"; +import { generateStacks } from "../compose/mod.ts"; +import type { GenerateOptions } from "../compose/mod.ts"; +import { join } from "@std/path"; +import { exists } from "@std/fs"; /** * Parse and execute CLI commands. @@ -29,7 +36,8 @@ export function buildCli(): Command { "Manage Docker Swarm stacks with generation, rendering, secrets, and lifecycle commands.", ) .help({ hints: true }) - .option("--debug", "Enable debug output and stack traces.", { hidden: false }); + .option("--debug", "Enable debug output and stack traces.", { hidden: false }) + .option("--config ", "Path to .stackctl config file.", { hidden: false }); // Default action: show help when no subcommand matches cli.action(() => { @@ -45,9 +53,45 @@ export function buildCli(): Command { .option("--write-gitignore", "Append .stackctl.local and .env to .gitignore.") .option("--force", "Overwrite existing .stackctl file.") .option("--dry-run", "Print the config that would be written without writing.") - .action(() => { - console.error("init: not yet implemented (issue #3)"); - Deno.exit(1); + .action(async (options: Record) => { + const detect = options.detect as boolean | undefined; + const preset = options.preset as string | undefined; + const profile = options.profile as string | undefined; + const writeGitignore = options.writeGitignore as boolean | undefined; + const force = options.force as boolean | undefined; + const dryRun = options.dryRun as boolean | undefined; + + const result = await initConfig({ + detect, + preset, + profile, + force, + dryRun, + cwd: Deno.cwd(), + }); + + for (const err of result.errors) { + console.error(`error: ${err}`); + } + + if (result.errors.length > 0) { + Deno.exit(2); // ExitCode.UserConfigError + } + + if (dryRun) { + for (const file of result.written) { + console.log(`would write: ${file}`); + } + } else { + for (const file of result.written) { + console.log(`wrote: ${file}`); + } + } + + // Handle --write-gitignore + if (writeGitignore) { + await appendGitignore(Deno.cwd()); + } }); // --- generate (issue #4) --- @@ -56,9 +100,54 @@ export function buildCli(): Command { .option("--stacks ", "Comma-separated list of stack names to generate.") .option("--output-dir ", "Write generated stacks to a specific directory.") .option("--profile ", "Use a specific profile.") - .action(() => { - console.error("generate: not yet implemented (issue #4)"); - Deno.exit(1); + .action(async (options: Record) => { + try { + const profile = options.profile as string | undefined; + const dryRun = options.dryRun as boolean | undefined; + + const config = await resolveConfig({ profile, cwd: Deno.cwd() }); + const repoRoot = config.base.repoRoot ?? Deno.cwd(); + + const genOptions: GenerateOptions = { + stacks: options.stacks + ? (options.stacks as string).split(",").map((s: string) => s.trim()) + : undefined, + configStackNames: config.base.stack.names, + repoRoot, + outputDir: options.outputDir as string | undefined, + dryRun, + network: config.base.stack.network, + }; + + const result = await generateStacks(genOptions); + + // Print warnings + for (const w of result.warnings) { + console.error(`warning: ${w}`); + } + + // Print errors + if (result.errors.length > 0) { + for (const e of result.errors) { + console.error(`error: ${e}`); + } + Deno.exit(ExitCode.DriftOrValidation); + } + + if (dryRun) { + for (const [name, content] of Object.entries(result.generated)) { + console.log(`# --- stack: ${name} ---`); + console.log(content); + } + } else { + for (const f of result.files) { + console.log(`wrote: ${f}`); + } + } + } catch (err: unknown) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + Deno.exit(ExitCode.UnexpectedError); + } }); // --- render (issue #5) --- @@ -247,3 +336,35 @@ export function buildCli(): Command { return cli as unknown as Command; } + +/** + * Append stackctl-specific entries to .gitignore. + */ +async function appendGitignore(cwd: string): Promise { + const gitignorePath = join(cwd, ".gitignore"); + const entries = [ + "# stackctl generated files", + ".stackctl.local", + ".stackctl.local.*", + ".env", + ".env.*", + "!.env.example", + ]; + + let existing = ""; + if (await exists(gitignorePath)) { + existing = await Deno.readTextFile(gitignorePath); + if (!existing.endsWith("\n")) existing += "\n"; + } + + // Check which entries are already present + const newEntries = entries.filter((e) => !existing.includes(e)); + if (newEntries.length === 0) { + console.log(".gitignore already up to date"); + return; + } + + const toAppend = (existing ? "\n" : "") + newEntries.join("\n") + "\n"; + await Deno.writeTextFile(gitignorePath, existing + toAppend); + console.log(`updated: ${gitignorePath}`); +} diff --git a/src/cli/mod_test.ts b/src/cli/mod_test.ts index 5e03bae..e81daa7 100644 --- a/src/cli/mod_test.ts +++ b/src/cli/mod_test.ts @@ -6,19 +6,19 @@ Deno.test("buildCli returns stackctl command", () => { assertEquals(cmd.getName(), "stackctl"); }); -Deno.test("main returns 1 for init (unimplemented)", async () => { +Deno.test("main returns 0 for init (dry-run)", async () => { // Override Deno.exit to prevent actual exit during test const origExit = Deno.exit; - Deno.exit = (_code?: number) => { - throw new Error("exit"); + Deno.exit = (code?: number) => { + throw new Error(`exit ${code}`); }; const { main } = await import("../cli/mod.ts"); try { - const code = await main(["init"]); - assertEquals(code, 1); + const code = await main(["init", "--dry-run"]); + assertEquals(code, 0); } catch { - // exit was called + // exit was called; init should not exit on success with --dry-run } Deno.exit = origExit; diff --git a/src/compose/discover.ts b/src/compose/discover.ts new file mode 100644 index 0000000..806df7a --- /dev/null +++ b/src/compose/discover.ts @@ -0,0 +1,110 @@ +/** + * Compose file discovery: walk repo directories to find docker-compose files + * annotated with `x-stack` metadata. + */ +import { walk } from "@std/fs/walk"; +import { parse as parseYaml } from "@std/yaml"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DiscoverOptions { + /** Repository root directory. */ + repoRoot: string; + /** Extra directories to skip beyond the defaults. */ + skipDirs?: string[]; +} + +export interface DiscoverResult { + /** Map of stack name -> list of compose file paths belonging to that stack. */ + stacks: Record; + /** Errors encountered (malformed YAML files). */ + errors: { path: string; message: string }[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Directories always skipped during discovery. */ +const DEFAULT_SKIP_DIRS = new Set([ + "node_modules", + "stacks", + "tools", + "environments", + "__pycache__", +]); + +/** Compose file names to search for. */ +const COMPOSE_NAMES = ["docker-compose.yml", "docker-compose.yaml"]; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Walk the repository root recursively looking for docker-compose files + * that declare an `x-stack` key. Groups discovered files by stack name. + */ +export async function discoverComposeFiles( + options: DiscoverOptions, +): Promise { + const stacks: Record = {}; + const errors: { path: string; message: string }[] = []; + const skipDirs = new Set([ + ...DEFAULT_SKIP_DIRS, + ...(options.skipDirs ?? []), + ]); + + for await ( + const entry of walk(options.repoRoot, { + includeDirs: false, + includeFiles: true, + skip: [ + // hidden directories (dot-prefixed) + /(^|\/)\./, + ], + }) + ) { + const name = entry.path.split("/").pop()!; + if (!COMPOSE_NAMES.includes(name)) continue; + + const dir = entry.path.substring(0, entry.path.lastIndexOf("/")); + + // Skip if any ancestor directory is in the skip set + if (hasSkipAncestor(dir, skipDirs)) continue; + + try { + const raw = await Deno.readTextFile(entry.path); + const parsed = parseYaml(raw) as Record | null; + if (!parsed || typeof parsed !== "object") continue; + + const stackName = parsed["x-stack"]; + if (typeof stackName !== "string" || stackName.trim() === "") continue; + + const nameStr = stackName.trim(); + if (!stacks[nameStr]) stacks[nameStr] = []; + stacks[nameStr].push(entry.path); + } catch (err: unknown) { + errors.push({ + path: entry.path, + message: err instanceof Error ? err.message : String(err), + }); + } + } + + return { stacks, errors }; +} + +/** + * Check if any ancestor directory of `dir` is in the skip set. + */ +function hasSkipAncestor(dir: string, skipDirs: Set): boolean { + // Normalise to relative path from repo root + const parts = dir.split("/").filter(Boolean); + for (const part of parts) { + if (skipDirs.has(part)) return true; + } + return false; +} diff --git a/src/compose/discover_test.ts b/src/compose/discover_test.ts new file mode 100644 index 0000000..80f5234 --- /dev/null +++ b/src/compose/discover_test.ts @@ -0,0 +1,140 @@ +/** + * Tests for compose file discovery. + */ +import { assertEquals } from "@std/assert"; +import { stringify as stringifyYaml } from "@std/yaml"; +import { discoverComposeFiles } from "./discover.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-discover-" }); +} + +async function writeYaml(dir: string, name: string, content: Record) { + const yaml = stringifyYaml(content, { indent: 2 } as Record); + await Deno.writeTextFile(`${dir}/${name}`, yaml); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +Deno.test("discover: finds compose files with x-stack", async () => { + const tmp = await makeTempDir(); + + await writeYaml(tmp, "docker-compose.yml", { + "x-stack": "infra", + services: { app: { image: "alpine" } }, + }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), ["infra"]); + assertEquals(result.stacks["infra"].length, 1); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: finds docker-compose.yaml files", async () => { + const tmp = await makeTempDir(); + + await writeYaml(tmp, "docker-compose.yaml", { + "x-stack": "platform", + }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), ["platform"]); + assertEquals(result.stacks["platform"].length, 1); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: skips files without x-stack", async () => { + const tmp = await makeTempDir(); + + await writeYaml(tmp, "docker-compose.yml", { + services: { app: { image: "alpine" } }, + }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), []); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: groups files by stack name", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/svc-a`); + await Deno.mkdir(`${tmp}/svc-b`); + + await writeYaml(`${tmp}/svc-a`, "docker-compose.yml", { "x-stack": "infra" }); + await writeYaml(`${tmp}/svc-b`, "docker-compose.yml", { "x-stack": "infra" }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), ["infra"]); + assertEquals(result.stacks["infra"].length, 2); + assertEquals(result.errors, []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: skips hidden directories", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/.hidden`); + + await writeYaml(`${tmp}/.hidden`, "docker-compose.yml", { "x-stack": "should-not-find" }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: skips node_modules", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/node_modules`); + + await writeYaml(`${tmp}/node_modules`, "docker-compose.yml", { "x-stack": "should-not-find" }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: skips stacks directory", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/stacks`); + + await writeYaml(`${tmp}/stacks`, "docker-compose.yml", { "x-stack": "should-not-find" }); + + const result = await discoverComposeFiles({ repoRoot: tmp }); + + assertEquals(Object.keys(result.stacks), []); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("discover: skips skipDirs from config", async () => { + const tmp = await makeTempDir(); + await Deno.mkdir(`${tmp}/vendor`); + + await writeYaml(`${tmp}/vendor`, "docker-compose.yml", { "x-stack": "should-not-find" }); + + const result = await discoverComposeFiles({ repoRoot: tmp, skipDirs: ["vendor"] }); + + assertEquals(Object.keys(result.stacks), []); + + await Deno.remove(tmp, { recursive: true }); +}); diff --git a/src/compose/generate.ts b/src/compose/generate.ts new file mode 100644 index 0000000..9efdaeb --- /dev/null +++ b/src/compose/generate.ts @@ -0,0 +1,201 @@ +/** + * Stack generation pipeline — the core of `stackctl generate`. + * + * Orchestrates discovery, loading, merging, transforming, and serialising + * compose files into canonical Swarm-ready stack files. + */ +import { stringify as stringifyYaml } from "@std/yaml"; +import { join } from "@std/path"; +import { ensureDir } from "@std/fs/ensure-dir"; +import { discoverComposeFiles } from "./discover.ts"; +import { loadCompose, loadFragment } from "./load.ts"; +import { composeDeepMerge } from "./merge.ts"; +import { + applyLoggingDefaults, + rewriteBindMountPaths, + rewriteEnvFile, + stripComposeOnlyKeys, +} from "./transform.ts"; +import { collectAllNamedVolumes } from "./volumes.ts"; +import type { ComposeData, ServiceDef } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface GenerateOptions { + /** Stack names to generate (undefined = all discovered). */ + stacks?: string[]; + /** Repository root path. */ + repoRoot: string; + /** Output directory for generated stacks (default: /stacks). */ + outputDir?: string; + /** Whether this is a dry run (no files written). */ + dryRun?: boolean; +} + +export interface GenerateResult { + /** Map of stack name -> YAML string content. */ + generated: Record; + /** Warnings encountered (non-fatal). */ + warnings: string[]; + /** Errors encountered (non-fatal — some stacks may still succeed). */ + errors: string[]; + /** Files that were (or would be) written. */ + files: string[]; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const NETWORK_NAME = "traefik-public"; + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/** + * Generate canonical Swarm stack files from per-service Compose sources. + */ +export async function generateStacks( + options: GenerateOptions, +): Promise { + const outputDir = options.outputDir ?? join(options.repoRoot, "stacks"); + const result: GenerateResult = { + generated: {}, + warnings: [], + errors: [], + files: [], + }; + + // 1. Discover all compose files + const discovery = await discoverComposeFiles({ repoRoot: options.repoRoot }); + + for (const err of discovery.errors) { + result.warnings.push(`Discovery error at ${err.path}: ${err.message}`); + } + + // 2. Determine which stacks to generate + const targetStacks = options.stacks ?? Object.keys(discovery.stacks); + + if (targetStacks.length === 0) { + result.warnings.push("No stacks discovered"); + return result; + } + + // 3. Ensure output directory exists + if (!options.dryRun) { + await ensureDir(outputDir); + } + + // 4. Generate each stack + for (const stackName of targetStacks) { + try { + const composePaths = discovery.stacks[stackName]; + if (!composePaths || composePaths.length === 0) { + result.errors.push(`No compose files found for stack "${stackName}"`); + continue; + } + + const output = await generateSingleStack( + stackName, + composePaths, + options.repoRoot, + ); + + result.generated[stackName] = output; + + const outPath = join(outputDir, `${stackName}.yml`); + if (options.dryRun) { + result.files.push(outPath); + } else { + await Deno.writeTextFile(outPath, output); + result.files.push(outPath); + } + } catch (err: unknown) { + result.errors.push( + `Stack "${stackName}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Single-stack generation +// --------------------------------------------------------------------------- + +async function generateSingleStack( + _stackName: string, + composePaths: string[], + repoRoot: string, +): Promise { + // 1. Load all compose files + fragments + const sources = await Promise.all( + composePaths.map(async (path) => { + const composeDir = path.substring(0, path.lastIndexOf("/")); + const { data } = await loadCompose(path); + const fragment = await loadFragment(composeDir); + return { composePath: path, composeDir, data, fragment }; + }), + ); + + // 2. Merge: compose data + fragment per-source, then merge all into one + let merged: ComposeData = {}; + for (const src of sources) { + const combined = composeDeepMerge(src.data, src.fragment); + merged = composeDeepMerge(merged, combined); + } + + // 3. Transform services + if (merged.services) { + const transformed: Record = {}; + for (const [svcName, svc] of Object.entries(merged.services)) { + let t = stripComposeOnlyKeys(svc); + t = applyLoggingDefaults(t); + t = rewriteEnvFile(t, sources[0]?.composeDir ?? "", repoRoot); + t = rewriteBindMountPaths(t, sources[0]?.composeDir ?? "", repoRoot); + transformed[svcName] = t; + } + merged = { ...merged, services: transformed }; + } + + // 4. Collect named volumes + const namedVolumes = collectAllNamedVolumes(merged.services); + + // 5. Assemble output structure + const output: Record = {}; + + // Services + if (merged.services && Object.keys(merged.services).length > 0) { + output.services = merged.services; + } + + // Networks + output.networks = { + default: { + name: NETWORK_NAME, + external: true, + }, + }; + + // Volumes (only if named volumes exist) + if (namedVolumes.length > 0) { + const volumes: Record = {}; + for (const name of namedVolumes) { + volumes[name] = { external: true }; + } + output.volumes = volumes; + } + + // 6. Serialise to YAML + const header = "# Generated by stackctl generate — do not edit manually.\n"; + const body = stringifyYaml(output, { + indent: 2, + lineWidth: 120, + noRefs: true, + } as Record); + return header + body; +} diff --git a/src/compose/generate_test.ts b/src/compose/generate_test.ts new file mode 100644 index 0000000..0a55e9c --- /dev/null +++ b/src/compose/generate_test.ts @@ -0,0 +1,256 @@ +/** + * Tests for the full stack generation pipeline. + */ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { generateStacks } from "./generate.ts"; +import type { GenerateOptions } from "./generate.ts"; + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-generate-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + await Deno.writeTextFile(`${dir}/${name}`, content); +} + +async function createFixture(repoRoot: string) { + // Create a service directory with compose + fragment + const svcDir = `${repoRoot}/services/web`; + await Deno.mkdir(svcDir, { recursive: true }); + + await writeFile( + svcDir, + "docker-compose.yml", + [ + "x-stack: platform", + "", + "services:", + " web:", + " image: nginx:alpine", + " ports:", + ' - "8080:80"', + " volumes:", + ' - "./html:/usr/share/nginx/html"', + ' - "app-data:/var/lib/data"', + " deploy:", + " replicas: 2", + "", + "volumes:", + " app-data:", + ].join("\n"), + ); + + await writeFile( + svcDir, + "swarm.fragment.yml", + [ + "services:", + " web:", + " logging:", + " driver: json-file", + "", + ].join("\n"), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +Deno.test("generateStacks: single stack dry-run returns content", async () => { + const tmp = await makeTempDir(); + await createFixture(tmp); + + const options: GenerateOptions = { + stacks: ["platform"], + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); + + assertEquals(result.errors, []); + assertEquals(Object.keys(result.generated).length, 1); + assertEquals(result.files.length, 1); + + const content = result.generated["platform"]; + // Should have the header comment + assertStringIncludes(content, "# Generated by stackctl generate"); + // Should contain the service + assertStringIncludes(content, "web:"); + // Should have the network block + assertStringIncludes(content, "traefik-public"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: writes files when not dry-run", async () => { + const tmp = await makeTempDir(); + await createFixture(tmp); + + const options: GenerateOptions = { + stacks: ["platform"], + repoRoot: tmp, + outputDir: `${tmp}/stacks`, + dryRun: false, + }; + + const result = await generateStacks(options); + + assertEquals(result.errors, []); + assertEquals(result.files.length, 1); + assertEquals(result.files[0], `${tmp}/stacks/platform.yml`); + + // Verify file was actually written + const written = await Deno.readTextFile(`${tmp}/stacks/platform.yml`); + assertStringIncludes(written, "# Generated by stackctl generate"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: generates all stacks when no filter", async () => { + const tmp = await makeTempDir(); + + // Create two stacks + const svcA = `${tmp}/services/api`; + const svcB = `${tmp}/services/db`; + await Deno.mkdir(svcA, { recursive: true }); + await Deno.mkdir(svcB, { recursive: true }); + + await writeFile( + svcA, + "docker-compose.yml", + [ + "x-stack: infra", + "services:", + " api:", + " image: node:20", + ].join("\n"), + ); + + await writeFile( + svcB, + "docker-compose.yml", + [ + "x-stack: platform", + "services:", + " db:", + " image: postgres:16", + ].join("\n"), + ); + + const options: GenerateOptions = { + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); + + assertEquals(result.errors, []); + assertEquals(Object.keys(result.generated).length, 2); + + const content1 = result.generated["infra"]; + const content2 = result.generated["platform"]; + assertStringIncludes(content1, "api:"); + assertStringIncludes(content2, "db:"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: reports error for nonexistent stack", async () => { + const tmp = await makeTempDir(); + await createFixture(tmp); + + const options: GenerateOptions = { + stacks: ["nonexistent"], + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); + + assertEquals(result.errors.length, 1); + assertStringIncludes(result.errors[0], "nonexistent"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: warnings for empty repo", async () => { + const tmp = await makeTempDir(); + + const options: GenerateOptions = { + repoRoot: tmp, + dryRun: true, + }; + + const result = await generateStacks(options); + + assertEquals(result.warnings.length, 1); + assertStringIncludes(result.warnings[0], "No stacks discovered"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: output contains default network", async () => { + const tmp = await makeTempDir(); + await createFixture(tmp); + + const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); + + const content = result.generated["platform"]; + assertStringIncludes(content, "networks:"); + assertStringIncludes(content, "default:"); + assertStringIncludes(content, "traefik-public"); + assertStringIncludes(content, "external: true"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: output includes named volumes as external", async () => { + const tmp = await makeTempDir(); + await createFixture(tmp); + + const result = await generateStacks({ stacks: ["platform"], repoRoot: tmp, dryRun: true }); + + const content = result.generated["platform"]; + assertStringIncludes(content, "volumes:"); + assertStringIncludes(content, "app-data:"); + assertStringIncludes(content, "external: true"); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("generateStacks: services stripped of compose-only keys", async () => { + const tmp = await makeTempDir(); + const svcDir = `${tmp}/services/app`; + await Deno.mkdir(svcDir, { recursive: true }); + + await writeFile( + svcDir, + "docker-compose.yml", + [ + "x-stack: infra", + "services:", + " app:", + " image: alpine", + " container_name: my-container", + " restart: always", + " build: .", + " deploy:", + " replicas: 1", + ].join("\n"), + ); + + const result = await generateStacks({ stacks: ["infra"], repoRoot: tmp, dryRun: true }); + + const content = result.generated["infra"]; + // container_name, restart, build must NOT appear + assertEquals(content.includes("container_name"), false); + assertEquals(content.includes("restart:"), false); + assertEquals(content.includes("build:"), false); + // deploy and image must stay + assertStringIncludes(content, "deploy:"); + assertStringIncludes(content, "image: alpine"); + + await Deno.remove(tmp, { recursive: true }); +}); diff --git a/src/compose/load.ts b/src/compose/load.ts new file mode 100644 index 0000000..52fdd35 --- /dev/null +++ b/src/compose/load.ts @@ -0,0 +1,70 @@ +/** + * Low-level compose file loader. + * + * Parses docker-compose YAML files and optional swarm.fragment.yml + * sidecar files from the same directory. + */ +import { parse as parseYaml } from "@std/yaml"; +import { resolve } from "@std/path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface LoadResult { + /** Parsed compose data with `x-stack` key removed. */ + data: Record; + /** Value of the `x-stack` key (the stack name). */ + stackName: string; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Load a docker-compose YAML file and extract its `x-stack` value. + * + * Throws if the file cannot be parsed or if `x-stack` is missing / non-string. + */ +export async function loadCompose(path: string): Promise { + const raw = await Deno.readTextFile(path); + let parsed: Record; + try { + parsed = (parseYaml(raw) ?? {}) as Record; + } catch (err: unknown) { + throw new Error( + `Failed to parse compose file ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const stackName = parsed["x-stack"]; + if (typeof stackName !== "string" || stackName.trim() === "") { + throw new Error(`Compose file ${path} is missing a valid "x-stack" key`); + } + + // Return a copy with x-stack removed + const { "x-stack": _, ...data } = parsed; + return { data, stackName: stackName.trim() }; +} + +/** + * Load a swarm.fragment.yml from the given directory. + * + * Returns an empty object (`{}`) if the file does not exist. + * Throws if the file exists but cannot be parsed. + */ +export async function loadFragment(directory: string): Promise> { + const fragmentPath = resolve(directory, "swarm.fragment.yml"); + try { + const raw = await Deno.readTextFile(fragmentPath); + const parsed = parseYaml(raw); + return (parsed ?? {}) as Record; + } catch (err: unknown) { + // ENOENT means the file doesn't exist — return empty object + if (err instanceof Deno.errors.NotFound) { + return {}; + } + throw err; + } +} diff --git a/src/compose/load_test.ts b/src/compose/load_test.ts new file mode 100644 index 0000000..d526835 --- /dev/null +++ b/src/compose/load_test.ts @@ -0,0 +1,110 @@ +/** + * Tests for compose file loading. + */ +import { assertEquals, assertRejects } from "@std/assert"; +import { loadCompose, loadFragment } from "./load.ts"; + +async function makeTempDir(): Promise { + return await Deno.makeTempDir({ prefix: "stackctl-test-load-" }); +} + +async function writeFile(dir: string, name: string, content: string) { + await Deno.writeTextFile(`${dir}/${name}`, content); +} + +Deno.test("loadCompose: parses valid compose with x-stack", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "x-stack: infra", + "services:", + " app:", + " image: alpine", + " ports:", + ' - "8080:80"', + ].join("\n"), + ); + + const result = await loadCompose(`${tmp}/docker-compose.yml`); + + assertEquals(result.stackName, "infra"); + assertEquals(result.data.services, { app: { image: "alpine", ports: ["8080:80"] } }); + // x-stack should be removed + assertEquals((result.data as Record)["x-stack"], undefined); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadCompose: throws on missing x-stack", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + await assertRejects( + () => loadCompose(`${tmp}/docker-compose.yml`), + Error, + "x-stack", + ); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadCompose: throws on empty x-stack value", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "docker-compose.yml", + [ + 'x-stack: ""', + "services:", + " app:", + " image: alpine", + ].join("\n"), + ); + + await assertRejects( + () => loadCompose(`${tmp}/docker-compose.yml`), + Error, + "x-stack", + ); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadFragment: returns {} when fragment is absent", async () => { + const tmp = await makeTempDir(); + + const result = await loadFragment(tmp); + + assertEquals(result, {}); + + await Deno.remove(tmp, { recursive: true }); +}); + +Deno.test("loadFragment: returns data when fragment exists", async () => { + const tmp = await makeTempDir(); + await writeFile( + tmp, + "swarm.fragment.yml", + [ + "deploy:", + " mode: global", + " replicas: 3", + ].join("\n"), + ); + + const result = await loadFragment(tmp); + + assertEquals(result, { deploy: { mode: "global", replicas: 3 } }); + + await Deno.remove(tmp, { recursive: true }); +}); diff --git a/src/compose/merge.ts b/src/compose/merge.ts new file mode 100644 index 0000000..1e93881 --- /dev/null +++ b/src/compose/merge.ts @@ -0,0 +1,59 @@ +/** + * Compose-specific deep merge utility. + * + * Distinct from config/merge.ts — this operates on untyped ComposeData + * (Record) and follows composition rules: + * + * - Dicts: merged recursively (override wins on scalar conflicts) + * - Lists: override REPLACES base (no appending) + * - Scalars: override wins + * - Neither argument is mutated — returns a fresh object. + */ +import type { ComposeData } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Recursively merge `override` into `base` for compose structures. + * + * Neither argument is mutated. Returns a new object. + */ +export function composeDeepMerge( + base: ComposeData, + override: ComposeData, +): ComposeData { + return deepMergeRecord(base, override) as ComposeData; +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +function deepMergeRecord( + base: Record, + override: Record, +): Record { + const result: Record = { ...base }; + + for (const key of Object.keys(override)) { + const overrideVal = override[key]; + const baseVal = base[key]; + + if (isPlainObject(overrideVal) && isPlainObject(baseVal)) { + result[key] = deepMergeRecord( + baseVal as Record, + overrideVal as Record, + ); + } else { + result[key] = overrideVal; + } + } + + return result; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/compose/merge_test.ts b/src/compose/merge_test.ts new file mode 100644 index 0000000..c63b818 --- /dev/null +++ b/src/compose/merge_test.ts @@ -0,0 +1,86 @@ +/** + * Tests for compose deep merge. + */ +import { assertEquals } from "@std/assert"; +import { composeDeepMerge } from "./merge.ts"; + +Deno.test("composeDeepMerge: scalar override", () => { + const base = { a: 1, b: 2 }; + const result = composeDeepMerge(base, { b: 99 }); + assertEquals(result, { a: 1, b: 99 }); +}); + +Deno.test("composeDeepMerge: dict recursive merge", () => { + const base = { top: { a: 1, b: 2, deep: { x: 10 } } }; + const override = { top: { b: 99, c: 3, deep: { y: 20 } } }; + const result = composeDeepMerge(base, override); + assertEquals(result, { + top: { a: 1, b: 99, c: 3, deep: { x: 10, y: 20 } }, + }); +}); + +Deno.test("composeDeepMerge: array replacement (not concatenation)", () => { + const base = { items: [1, 2, 3] }; + const result = composeDeepMerge(base, { items: [4, 5] }); + assertEquals(result, { items: [4, 5] }); +}); + +Deno.test("composeDeepMerge: empty override leaves base unchanged", () => { + const base = { a: 1, b: { c: 2 } }; + const result = composeDeepMerge(base, {}); + assertEquals(result, { a: 1, b: { c: 2 } }); +}); + +Deno.test("composeDeepMerge: empty base filled by override", () => { + const base = {}; + const result = composeDeepMerge(base, { a: 1, b: [2, 3] }); + assertEquals(result, { a: 1, b: [2, 3] }); +}); + +Deno.test("composeDeepMerge: adds new keys from override", () => { + const base = { existing: true }; + const result = composeDeepMerge(base, { newKey: "hello" }); + assertEquals(result, { existing: true, newKey: "hello" }); +}); + +Deno.test("composeDeepMerge: does not mutate base", () => { + const base = { a: 1 }; + const override = { b: 2 }; + composeDeepMerge(base, override); + assertEquals(base, { a: 1 }); // base unchanged +}); + +Deno.test("composeDeepMerge: does not mutate override", () => { + const base = { a: 1 }; + const override = { b: 2 }; + composeDeepMerge(base, override); + assertEquals(override, { b: 2 }); // override unchanged +}); + +Deno.test("composeDeepMerge: deeply nested merge with arrays replaced", () => { + const base = { a: { b: { names: ["old"], network: "old-net" } } }; + const override = { a: { b: { names: ["new1", "new2"] } } }; + const result = composeDeepMerge(base, override); + assertEquals(result, { + a: { b: { names: ["new1", "new2"], network: "old-net" } }, + }); +}); + +Deno.test("composeDeepMerge: service merge pattern", () => { + const base = { + services: { + app: { image: "old", ports: ["8080:80"] }, + }, + }; + const override = { + services: { + app: { image: "new", environment: { FOO: "bar" } }, + }, + }; + const result = composeDeepMerge(base, override); + assertEquals(result, { + services: { + app: { image: "new", ports: ["8080:80"], environment: { FOO: "bar" } }, + }, + }); +}); diff --git a/src/compose/mod.ts b/src/compose/mod.ts new file mode 100644 index 0000000..46512fd --- /dev/null +++ b/src/compose/mod.ts @@ -0,0 +1,11 @@ +/** + * Compose module — stack generation from per-service Compose sources. + */ +export * from "./types.ts"; +export * from "./discover.ts"; +export * from "./load.ts"; +export * from "./merge.ts"; +export * from "./transform.ts"; +export * from "./volumes.ts"; +export { generateStacks } from "./generate.ts"; +export type { GenerateOptions, GenerateResult } from "./generate.ts"; diff --git a/src/compose/transform.ts b/src/compose/transform.ts new file mode 100644 index 0000000..cb26a31 --- /dev/null +++ b/src/compose/transform.ts @@ -0,0 +1,157 @@ +/** + * Service-level transformations for Swarm compatibility. + * + * Each function takes a ServiceDef and returns a new ServiceDef — the originals + * are never mutated. + */ +import { relative, resolve } from "@std/path"; +import type { ServiceDef, VolumeMount } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Top-level service keys that are invalid in Docker Swarm mode. */ +const COMPOSE_ONLY_KEYS = new Set(["container_name", "restart", "build"]); + +/** Default logging configuration injected when a service lacks a logging block. */ +const LOGGING_DEFAULTS: Record = { + driver: "local", + options: { + "max-size": "10m", + "max-file": 3, + }, +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Remove keys from a service definition that are invalid in Docker Swarm mode. + */ +export function stripComposeOnlyKeys(service: ServiceDef): ServiceDef { + const result: ServiceDef = {}; + for (const [key, value] of Object.entries(service)) { + if (!COMPOSE_ONLY_KEYS.has(key)) { + result[key] = value; + } + } + return result; +} + +/** + * Inject safe logging defaults when no logging block exists on the service. + */ +export function applyLoggingDefaults(service: ServiceDef): ServiceDef { + if (service.logging) return service; // already present — leave as-is + return { ...service, logging: LOGGING_DEFAULTS }; +} + +/** + * Rewrite relative `env_file` paths to be relative to `repoRoot`. + * + * Absolute paths are left unchanged. + */ +export function rewriteEnvFile( + service: ServiceDef, + composeDir: string, + repoRoot: string, +): ServiceDef { + if (!service.env_file) return service; + + const rewritten = Array.isArray(service.env_file) + ? service.env_file.map((p) => toRepoRootRel(p, composeDir, repoRoot)) + : toRepoRootRel(service.env_file, composeDir, repoRoot); + + return { ...service, env_file: rewritten }; +} + +/** + * Rewrite relative bind-mount source paths in `volumes` to be repo-root-relative. + * + * - Short-form strings: split on `:`, check if the source part is a relative path. + * - Long-form dicts: check if `type` is "bind" (or absent) and `source` is a relative path. + */ +export function rewriteBindMountPaths( + service: ServiceDef, + composeDir: string, + repoRoot: string, +): ServiceDef { + if (!service.volumes) return service; + + const rewritten = service.volumes.map((vm) => rewriteBindMount(vm, composeDir, repoRoot)); + + return { ...service, volumes: rewritten }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert a relative path to one relative to repoRoot. + */ +function toRepoRootRel(path: string, composeDir: string, repoRoot: string): string { + if (path.startsWith("/")) return path; // already absolute + const clean = path.startsWith("./") ? path.slice(2) : path; + const absPath = resolve(composeDir, clean); + const rel = relative(repoRoot, absPath); + return `./${rel}`; +} + +/** + * Rewrite a single volume mount entry. + */ +function rewriteBindMount( + mount: VolumeMount, + composeDir: string, + repoRoot: string, +): VolumeMount { + if (typeof mount === "string") { + return rewriteBindMountString(mount, composeDir, repoRoot); + } + + // Long-form dict + const type = mount.type; + if (type === "volume") return mount; // named volumes — skip + + // bind or missing type (treated as bind) + if (typeof mount.source === "string" && !mount.source.startsWith("/")) { + return { ...mount, source: toRepoRootRel(mount.source, composeDir, repoRoot) }; + } + return mount; +} + +/** + * Rewrite a short-form volume mount string. + * + * Format: `[source:]target[:mode]` + * If the source component is a relative path, rewrite it. + */ +function rewriteBindMountString( + mount: string, + composeDir: string, + repoRoot: string, +): string { + if (!isBindMountString(mount)) return mount; + + const parts = mount.split(":"); + // At least [source:target], possibly [source:target:mode] + if (parts.length >= 2) { + const source = parts[0]; + if (source.startsWith("/") || source.startsWith("~")) return mount; + parts[0] = toRepoRootRel(source, composeDir, repoRoot); + return parts.join(":"); + } + return mount; +} + +/** + * Check if a short-form volume string is a bind mount (not a named volume). + * Named volumes don't start with `.`, `/`, or `~`. + */ +function isBindMountString(mount: string): boolean { + const source = mount.split(":")[0]; + return source.startsWith(".") || source.startsWith("/") || source.startsWith("~"); +} diff --git a/src/compose/transform_test.ts b/src/compose/transform_test.ts new file mode 100644 index 0000000..3283651 --- /dev/null +++ b/src/compose/transform_test.ts @@ -0,0 +1,171 @@ +/** + * Tests for service transformation functions. + */ +import { assertEquals } from "@std/assert"; +import { + applyLoggingDefaults, + rewriteBindMountPaths, + rewriteEnvFile, + stripComposeOnlyKeys, +} from "./transform.ts"; +import type { ServiceDef } from "./types.ts"; + +// --------------------------------------------------------------------------- +// stripComposeOnlyKeys +// --------------------------------------------------------------------------- + +Deno.test("stripComposeOnlyKeys: removes container_name, restart, build", () => { + const svc: ServiceDef = { + image: "alpine", + container_name: "my-app", + restart: "always", + build: ".", + ports: ["8080:80"], + }; + const result = stripComposeOnlyKeys(svc); + assertEquals(result, { image: "alpine", ports: ["8080:80"] }); +}); + +Deno.test("stripComposeOnlyKeys: preserves other keys", () => { + const svc: ServiceDef = { + image: "alpine", + deploy: { replicas: 3 }, + environment: { FOO: "bar" }, + volumes: ["data:/data"], + }; + const result = stripComposeOnlyKeys(svc); + assertEquals(result, svc); +}); + +Deno.test("stripComposeOnlyKeys: handles empty service", () => { + const svc: ServiceDef = {}; + const result = stripComposeOnlyKeys(svc); + assertEquals(result, {}); +}); + +Deno.test("stripComposeOnlyKeys: does not mutate input", () => { + const svc: ServiceDef = { image: "alpine", container_name: "app" }; + stripComposeOnlyKeys(svc); + assertEquals(svc, { image: "alpine", container_name: "app" }); +}); + +// --------------------------------------------------------------------------- +// applyLoggingDefaults +// --------------------------------------------------------------------------- + +Deno.test("applyLoggingDefaults: adds logging when absent", () => { + const svc: ServiceDef = { image: "alpine" }; + const result = applyLoggingDefaults(svc); + assertEquals(result.logging, { + driver: "local", + options: { "max-size": "10m", "max-file": 3 }, + }); + assertEquals(result.image, "alpine"); +}); + +Deno.test("applyLoggingDefaults: preserves existing logging", () => { + const svc: ServiceDef = { + image: "alpine", + logging: { driver: "json-file" }, + }; + const result = applyLoggingDefaults(svc); + assertEquals(result.logging, { driver: "json-file" }); +}); + +Deno.test("applyLoggingDefaults: does not mutate input", () => { + const svc: ServiceDef = { image: "alpine" }; + applyLoggingDefaults(svc); + assertEquals(svc.logging, undefined); +}); + +// --------------------------------------------------------------------------- +// rewriteEnvFile +// --------------------------------------------------------------------------- + +Deno.test("rewriteEnvFile: relative path — single string", () => { + const svc: ServiceDef = { env_file: ".env" }; + const result = rewriteEnvFile(svc, "/project/services/web", "/project"); + const expected = ".env"; + const actual = result.env_file as string; + assertEquals(actual.startsWith("./"), true); + assertEquals(actual.endsWith(expected), true); +}); + +Deno.test("rewriteEnvFile: array of paths", () => { + const svc: ServiceDef = { env_file: [".env", ".env.prod"] }; + const result = rewriteEnvFile(svc, "/project/services/web", "/project"); + const arr = result.env_file as string[]; + assertEquals(Array.isArray(arr), true); + assertEquals(arr.length, 2); + assertEquals(arr[0].startsWith("./"), true); +}); + +Deno.test("rewriteEnvFile: absolute path unchanged", () => { + const svc: ServiceDef = { env_file: "/etc/env" }; + const result = rewriteEnvFile(svc, "/project/services/web", "/project"); + assertEquals(result.env_file, "/etc/env"); +}); + +Deno.test("rewriteEnvFile: no env_file — unchanged", () => { + const svc: ServiceDef = { image: "alpine" }; + const result = rewriteEnvFile(svc, "/a", "/b"); + assertEquals(result, svc); + // Should return a different reference or same? Since we spread only when env_file exists, + // we'll accept same reference since nothing changed. +}); + +// --------------------------------------------------------------------------- +// rewriteBindMountPaths +// --------------------------------------------------------------------------- + +Deno.test("rewriteBindMountPaths: relative bind mount string", () => { + const svc: ServiceDef = { + volumes: ["./data:/app/data"], + }; + const result = rewriteBindMountPaths(svc, "/project/services/web", "/project"); + const v = result.volumes?.[0] as string; + assertEquals(v.startsWith("./"), true); + assertEquals(v.includes(":"), true); + assertEquals(v.split(":")[0].startsWith("./"), true); +}); + +Deno.test("rewriteBindMountPaths: absolute bind mount unchanged", () => { + const svc: ServiceDef = { + volumes: ["/etc/data:/app/data"], + }; + const result = rewriteBindMountPaths(svc, "/project/services/web", "/project"); + assertEquals(result.volumes?.[0], "/etc/data:/app/data"); +}); + +Deno.test("rewriteBindMountPaths: named volume unchanged", () => { + const svc: ServiceDef = { + volumes: ["data-volume:/app/data"], + }; + const result = rewriteBindMountPaths(svc, "/project/services/web", "/project"); + assertEquals(result.volumes?.[0], "data-volume:/app/data"); +}); + +Deno.test("rewriteBindMountPaths: long-form bind mount", () => { + const svc: ServiceDef = { + volumes: [{ type: "bind", source: "./data", target: "/app/data" }], + }; + const result = rewriteBindMountPaths(svc, "/project/services/web", "/project"); + const v = result.volumes?.[0] as Record; + assertEquals((v.source as string).startsWith("./"), true); + assertEquals((v.source as string).startsWith("./data"), false); // should be repo-relative +}); + +Deno.test("rewriteBindMountPaths: long-form named volume unchanged", () => { + const svc: ServiceDef = { + volumes: [{ type: "volume", source: "data", target: "/app/data" }], + }; + const result = rewriteBindMountPaths(svc, "/project/services/web", "/project"); + const v = result.volumes?.[0] as Record; + assertEquals(v.source, "data"); +}); + +Deno.test("rewriteBindMountPaths: no volumes — unchanged", () => { + const svc: ServiceDef = { image: "alpine" }; + const result = rewriteBindMountPaths(svc, "/a", "/b"); + assertEquals(result, svc); +}); diff --git a/src/compose/types.ts b/src/compose/types.ts new file mode 100644 index 0000000..410c7fb --- /dev/null +++ b/src/compose/types.ts @@ -0,0 +1,42 @@ +/** + * Compose type definitions for stack generation. + */ + +/** Parsed compose data with stack metadata removed. */ +export interface ComposeData { + [key: string]: unknown; + services?: Record; + volumes?: Record; + networks?: Record; +} + +/** A single service definition (recursive, may be any YAML value). */ +export interface ServiceDef { + [key: string]: unknown; + image?: string; + env_file?: string | string[]; + volumes?: VolumeMount[]; + logging?: Record; +} + +/** Volume mount — either a short-form string or a long-form dict. */ +export type VolumeMount = + | string + | { + type?: string; + source?: string; + target?: string; + [key: string]: unknown; + }; + +/** Deep-merge rules for compose structures. */ +export type MergeMode = "compose" | "config"; + +/** Result of loading a compose file pair. */ +export interface ServiceSource { + composePath: string; + composeDir: string; + data: ComposeData; + stackName: string; + fragment: ComposeData; +} diff --git a/src/compose/volumes.ts b/src/compose/volumes.ts new file mode 100644 index 0000000..893b468 --- /dev/null +++ b/src/compose/volumes.ts @@ -0,0 +1,76 @@ +/** + * Named volume collection utilities. + * + * Extracts external named volume references from service volume mount lists. + */ +import type { ServiceDef, VolumeMount } from "./types.ts"; + +/** + * Extract named volume names from all services in a compose data object. + * + * Returns a deduplicated sorted array of volume names that should be declared + * as `external: true` volumes in the generated stack file. + */ +export function collectAllNamedVolumes( + services?: Record, +): string[] { + if (!services) return []; + + const seen = new Set(); + + for (const def of Object.values(services)) { + for (const vol of collectNamedVolumes(def.volumes)) { + seen.add(vol); + } + } + + return [...seen].sort(); +} + +/** + * Extract named volume names from a single service's volume list. + * + * - Named volumes do NOT start with `.`, `/`, or `~` (short-form string). + * - Named volumes have `type === "volume"` (long-form dict). + */ +export function collectNamedVolumes(volumes?: VolumeMount[]): string[] { + if (!volumes) return []; + + const names: string[] = []; + + for (const mount of volumes) { + const name = extractNamedVolume(mount); + if (name) names.push(name); + } + + return names; +} + +// --------------------------------------------------------------------------- +// Internal +// --------------------------------------------------------------------------- + +function extractNamedVolume(mount: VolumeMount): string | null { + if (typeof mount === "string") { + return extractFromString(mount); + } + return extractFromDict(mount); +} + +function extractFromString(mount: string): string | null { + // Format: [source:]target[:mode] + // If source starts with . / or ~ it's a bind mount, not a named volume. + const source = mount.split(":")[0]; + if (source.startsWith(".") || source.startsWith("/") || source.startsWith("~")) { + return null; + } + return source; +} + +function extractFromDict(mount: Record): string | null { + if (mount.type === "volume") { + return typeof mount.source === "string" ? mount.source : null; + } + // bind, tmpfs, npipe, or unspecified — skip + return null; +} diff --git a/src/compose/volumes_test.ts b/src/compose/volumes_test.ts new file mode 100644 index 0000000..01902a3 --- /dev/null +++ b/src/compose/volumes_test.ts @@ -0,0 +1,89 @@ +/** + * Tests for named volume collection. + */ +import { assertEquals } from "@std/assert"; +import { collectAllNamedVolumes, collectNamedVolumes } from "./volumes.ts"; +import type { ServiceDef, VolumeMount } from "./types.ts"; + +// --------------------------------------------------------------------------- +// collectNamedVolumes +// --------------------------------------------------------------------------- + +Deno.test("collectNamedVolumes: short-form named volume", () => { + const volumes: VolumeMount[] = ["data:/app/data"]; + const result = collectNamedVolumes(volumes); + assertEquals(result, ["data"]); +}); + +Deno.test("collectNamedVolumes: short-form bind mount (relative path)", () => { + const volumes: VolumeMount[] = ["./data:/app/data"]; + const result = collectNamedVolumes(volumes); + assertEquals(result, []); +}); + +Deno.test("collectNamedVolumes: short-form bind mount (absolute path)", () => { + const volumes: VolumeMount[] = ["/etc/config:/app/config"]; + const result = collectNamedVolumes(volumes); + assertEquals(result, []); +}); + +Deno.test("collectNamedVolumes: short-form bind mount (home path)", () => { + const volumes: VolumeMount[] = ["~/data:/app/data"]; + const result = collectNamedVolumes(volumes); + assertEquals(result, []); +}); + +Deno.test("collectNamedVolumes: long-form named volume", () => { + const volumes: VolumeMount[] = [{ type: "volume", source: "data", target: "/app/data" }]; + const result = collectNamedVolumes(volumes); + assertEquals(result, ["data"]); +}); + +Deno.test("collectNamedVolumes: long-form bind mount skipped", () => { + const volumes: VolumeMount[] = [{ type: "bind", source: "/host/path", target: "/app/data" }]; + const result = collectNamedVolumes(volumes); + assertEquals(result, []); +}); + +Deno.test("collectNamedVolumes: mixed short-form volumes", () => { + const volumes: VolumeMount[] = [ + "data:/app/data", + "./config:/app/config", + "logs:/var/log", + ]; + const result = collectNamedVolumes(volumes); + assertEquals(result, ["data", "logs"]); +}); + +Deno.test("collectNamedVolumes: empty list", () => { + const result = collectNamedVolumes([]); + assertEquals(result, []); +}); + +Deno.test("collectNamedVolumes: undefined input", () => { + const result = collectNamedVolumes(undefined); + assertEquals(result, []); +}); + +// --------------------------------------------------------------------------- +// collectAllNamedVolumes +// --------------------------------------------------------------------------- + +Deno.test("collectAllNamedVolumes: aggregates across services", () => { + const services: Record = { + svc1: { volumes: ["data:/data"] }, + svc2: { volumes: ["logs:/logs", "data:/data"] }, + }; + const result = collectAllNamedVolumes(services); + assertEquals(result, ["data", "logs"]); +}); + +Deno.test("collectAllNamedVolumes: undefined services", () => { + const result = collectAllNamedVolumes(undefined); + assertEquals(result, []); +}); + +Deno.test("collectAllNamedVolumes: empty services", () => { + const result = collectAllNamedVolumes({}); + assertEquals(result, []); +}); diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000..573a2ac --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,30 @@ +/** + * Default configuration values. + * + * These serve as the base layer for config resolution. + * All fields match the StackctlConfig type shape. + */ +import type { StackctlConfig } from "./types.ts"; + +export const DEFAULT_CONFIG: StackctlConfig = { + project: "", + stack: { + directory: "stacks", + names: [], + network: "", + composeStackKey: "x-stack", + skipDirectories: [], + networkDriver: "overlay", + }, + render: { + outputDirectory: ".rendered", + }, + env: { + activeName: ".env", + allowPlaintextProfiles: false, + }, + overrides: { + autoDiscoverProfiles: true, + exclude: [], + }, +}; diff --git a/src/config/init.ts b/src/config/init.ts new file mode 100644 index 0000000..c19cd4d --- /dev/null +++ b/src/config/init.ts @@ -0,0 +1,479 @@ +/** + * Config initialisation - generates `.stackctl` template files. + * + * Includes: + * - A default template with extensive inline comments + * - Optional auto-detection from docker-compose files + * - Preset configurations (minimal, standard) + * - Profile and force/dry-run support + * - .gitignore management integration + */ +import { exists } from "@std/fs"; +import { parse as parseYaml } from "@std/yaml"; +import { basename, join } from "@std/path"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface InitOptions { + /** Auto-detect layout from existing docker-compose files. */ + detect?: boolean; + /** Preset template name (e.g. "minimal", "standard"). */ + preset?: string; + /** Also create a profile config file. */ + profile?: string; + /** Append .stackctl.local and .env entries to .gitignore. */ + writeGitignore?: boolean; + /** Overwrite existing files. */ + force?: boolean; + /** Print what would be written without writing. */ + dryRun?: boolean; + /** Working directory. */ + cwd?: string; +} + +export interface InitResult { + /** Files that were (or would be) written. */ + written: string[]; + /** Errors encountered during initialisation. */ + errors: string[]; +} + +// --------------------------------------------------------------------------- +// Template (raw YAML with inline comments) +// --------------------------------------------------------------------------- + +const TEMPLATE = `# stackctl configuration +# Generated by \`stackctl init\` +# See https://github.com/AniTrend/stackctl for documentation + +# Project name used for stack naming and identification. +# This should match your repository or project name. +project: "" + +# Stack generation settings +stack: + # Subdirectory for generated stack files (default: "stacks") + directory: "stacks" + # Stack names to generate (one per service group). + # These are the top-level stack names, not individual services. + names: + - "example" # Replace with your stack names + # Compose metadata key for stack grouping (default: "x-stack") + composeStackKey: "x-stack" + # Directories to skip during service discovery + skipDirectories: [] + # External overlay network name for all stacks + network: "" + # Network driver (default: "overlay") + networkDriver: "overlay" + +# Rendering settings for env var interpolation +render: + # Output directory for rendered files (default: ".rendered") + outputDirectory: ".rendered" + +# Environment file settings +env: + # Active .env file name (default: ".env") + activeName: ".env" + # Allow plaintext profile env files (default: false) + allowPlaintextProfiles: false + +# (Optional) Secrets management with SOPS/age +# secrets: +# encryptedFileName: ".env.enc" + +# (Optional) Override file configuration +# overrides: +# autoDiscoverProfiles: true +# exclude: [] + +# (Optional) Command-specific defaults +# commands: +# up: +# followLogs: true +# reload: +# followLogs: false +# autoGenerate: true +# forceServiceUpdate: false +`; + +const MINIMAL_TEMPLATE = `# stackctl minimal configuration +# Generated by \`stackctl init --preset minimal\` +# See https://github.com/AniTrend/stackctl for documentation + +project: "" + +stack: + directory: "stacks" + names: + - "app" + network: "" + +render: + outputDirectory: ".rendered" + +env: + activeName: ".env" +`; + +// --------------------------------------------------------------------------- +// Presets +// --------------------------------------------------------------------------- + +const PRESETS: Record = { + minimal: MINIMAL_TEMPLATE, + standard: TEMPLATE, +}; + +// --------------------------------------------------------------------------- +// Init +// --------------------------------------------------------------------------- + +/** + * Generate one or more `.stackctl` configuration files. + */ +export async function initConfig(options: InitOptions): Promise { + const cwd = options.cwd ?? Deno.cwd(); + const result: InitResult = { written: [], errors: [] }; + + // Pick the template + let template = TEMPLATE; + if (options.preset && PRESETS[options.preset]) { + template = PRESETS[options.preset]; + } else if (options.preset) { + result.errors.push( + `Unknown preset: "${options.preset}". Available: ${Object.keys(PRESETS).join(", ")}`, + ); + return result; + } + + // Auto-detect layout from docker-compose files + if (options.detect) { + template = await applyDetection(template, cwd); + } + + // Write base config (validated before write) + const basePath = join(cwd, ".stackctl"); + await writeConfigFile(basePath, template, options, result); + + // Write profile config if requested (uses minimal profile template) + if (options.profile) { + const profileContent = buildProfileTemplate(options.profile); + const profilePath = join(cwd, `.stackctl.${options.profile}`); + await writeConfigFile(profilePath, profileContent, options, result); + } + + // Handle --write-gitignore + if (options.writeGitignore) { + await appendGitignore(cwd, result); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Profile template +// --------------------------------------------------------------------------- + +function buildProfileTemplate(profileName: string): string { + return `# stackctl profile overlay for '${profileName}' +# Generated by \`stackctl init --profile ${profileName}\` + +profile: "${profileName}" + +# (Optional) Override any base config fields below. +# Only specify the sections you want to override - profile files +# are merged on top of the base .stackctl config. +# +# Example: +# stack: +# network: "dev-net" +# env: +# activeName: ".env.dev" +`; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function writeConfigFile( + path: string, + content: string, + options: InitOptions, + result: InitResult, +): Promise { + const alreadyExists = await exists(path); + + if (alreadyExists && !options.force) { + result.errors.push( + `File already exists: ${path}. Use --force to overwrite.`, + ); + return; + } + + // Validate generated template YAML structure before writing + try { + const parsed = parseYaml(content) as Record; + if (!parsed || typeof parsed !== "object") { + result.errors.push( + `Generated config at ${path} resolves to invalid structure`, + ); + return; + } + } catch (err: unknown) { + result.errors.push( + `Failed to parse generated config (${path}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + return; + } + + if (options.dryRun) { + result.written.push(path); + return; + } + + try { + await Deno.writeTextFile(path, content); + result.written.push(path); + } catch (err: unknown) { + result.errors.push( + `Failed to write ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Append stackctl-specific entries to .gitignore. + */ +export async function appendGitignore( + cwd: string, + result?: InitResult, +): Promise { + const gitignorePath = join(cwd, ".gitignore"); + const entries = [ + "# stackctl generated files", + ".stackctl.local", + ".stackctl.local.*", + ".env", + ".env.*", + "!.env.example", + ]; + + let existing = ""; + if (await exists(gitignorePath)) { + existing = await Deno.readTextFile(gitignorePath); + if (!existing.endsWith("\n")) existing += "\n"; + } + + const newEntries = entries.filter((e) => !existing.includes(e)); + if (newEntries.length === 0) { + console.log(".gitignore already up to date"); + return; + } + + const toAppend = (existing ? "\n" : "") + newEntries.join("\n") + "\n"; + await Deno.writeTextFile(gitignorePath, existing + toAppend); + console.log(`updated: ${gitignorePath}`); + + if (result) { + result.written.push(gitignorePath); + } +} + +/** + * Scan the working directory for docker-compose files, subdirectories, + * fragments, secret files, and env files to pre-populate config values. + */ +async function applyDetection(template: string, cwd: string): Promise { + const composeFiles: string[] = []; + const fragments: string[] = []; + let hasSops = false; + let hasAgeKey = false; + let hasEnvExample = false; + let hasEnvEnc = false; + + for await (const entry of Deno.readDir(cwd)) { + const name = entry.name; + + if (entry.isFile) { + if ( + name === "docker-compose.yml" || + name === "docker-compose.yaml" || + name === "compose.yml" || + name === "compose.yaml" || + (name.startsWith("docker-compose.") && + (name.endsWith(".yml") || name.endsWith(".yaml"))) + ) { + composeFiles.push(join(cwd, name)); + } + + if (name.toLowerCase().includes("fragment")) { + fragments.push(name); + } + + if (name === ".sops.yaml") hasSops = true; + if (name === ".age-key") hasAgeKey = true; + if (name === ".env.example") hasEnvExample = true; + if (name === ".env.enc") hasEnvEnc = true; + } + + // Scan one level deep in subdirectories + if (entry.isDirectory) { + try { + const subDir = join(cwd, name); + for await (const subEntry of Deno.readDir(subDir)) { + if (subEntry.isFile) { + const subName = subEntry.name; + if ( + subName === "docker-compose.yml" || + subName === "docker-compose.yaml" || + subName === "compose.yml" || + subName === "compose.yaml" || + (subName.startsWith("docker-compose.") && + (subName.endsWith(".yml") || subName.endsWith(".yaml"))) + ) { + composeFiles.push(join(subDir, subName)); + } + if (subName.toLowerCase().includes("fragment")) { + fragments.push(join(name, subName)); + } + } + } + } catch { + // Skip unreadable subdirectories + } + } + + // Scan existing stacks/ directory + if (entry.isDirectory && name === "stacks") { + try { + const stacksDir = join(cwd, name); + for await (const stackEntry of Deno.readDir(stacksDir)) { + if (stackEntry.isFile) { + fragments.push(join("stacks", stackEntry.name)); + } + } + } catch { + // Skip unreadable stacks dir + } + } + } + + // Phase 2: Parse compose files + const stackNames: string[] = []; + const xStackGroups = new Map(); + let composeStackKey = ""; + let network = ""; + + for (const file of composeFiles) { + try { + const raw = await Deno.readTextFile(file); + const parsed = parseYaml(raw) as Record; + + if (parsed?.["x-stack"] && !composeStackKey) { + composeStackKey = parsed["x-stack"] as string; + } + + if (parsed?.services && typeof parsed.services === "object") { + const services = parsed.services as Record< + string, + Record + >; + for (const [svcName, svcDef] of Object.entries(services)) { + if (!stackNames.includes(svcName)) { + stackNames.push(svcName); + } + + const xStack = svcDef?.["x-stack"] as string | undefined; + if (xStack) { + const existing = xStackGroups.get(xStack) ?? []; + if (!existing.includes(svcName)) existing.push(svcName); + xStackGroups.set(xStack, existing); + } + } + } + + if (!network && parsed?.networks && typeof parsed.networks === "object") { + const nets = parsed.networks as Record; + const keys = Object.keys(nets); + const custom = keys.find( + (k) => k !== "default" && k !== "bridge" && k !== "host", + ); + if (custom) network = custom; + else if (keys.length > 0) { + const first = keys[0]; + if (first !== "default") network = first; + } + } + } catch { + // Skip unparseable files + } + } + + if ( + composeFiles.length === 0 && fragments.length === 0 && !hasEnvExample && + !hasEnvEnc + ) { + return template; + } + + // Phase 3: Inject detected values + let result = template; + + const finalStackNames = xStackGroups.size > 0 ? Array.from(xStackGroups.keys()) : stackNames; + + if (finalStackNames.length > 0) { + const namesYaml = finalStackNames.map((n) => ` - "${n}"`).join("\n"); + result = result.replace( + /(\n)(\s+)names:\n(\s+- "example".*\n)+/, + `$1$2names:\n${namesYaml}\n`, + ); + } + + if (network) { + result = result.replace( + /(\n)(\s+)network: ""/, + `$1$2network: "${network}"`, + ); + } + + if (composeStackKey && composeStackKey !== "x-stack") { + result = result.replace( + /composeStackKey: "x-stack"/, + `composeStackKey: "${composeStackKey}"`, + ); + } + + const dirName = basename(cwd); + if (dirName && dirName !== "." && dirName !== "/") { + result = result.replace( + /^project: ""$/m, + `project: "${dirName}"`, + ); + } + + const discoveries: string[] = []; + if (fragments.length > 0) { + discoveries.push( + `# Detected fragments:\n# ${fragments.join("\n# ")}`, + ); + } + if (hasSops) discoveries.push("# Detected: .sops.yaml (secrets tooling)"); + if (hasAgeKey) discoveries.push("# Detected: .age-key (age identity)"); + if (hasEnvExample) discoveries.push("# Detected: .env.example"); + if (hasEnvEnc) { + discoveries.push("# Detected: .env.enc (encrypted env file)"); + } + + if (discoveries.length > 0) { + result = result.trimEnd() + "\n\n" + discoveries.join("\n") + "\n"; + } + + return result; +} diff --git a/src/config/init_test.ts b/src/config/init_test.ts new file mode 100644 index 0000000..4946bce --- /dev/null +++ b/src/config/init_test.ts @@ -0,0 +1,189 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { parse as parseYaml } from "@std/yaml"; +import { initConfig } from "./init.ts"; +import { join } from "@std/path"; + +Deno.test("initConfig: default template contains expected sections", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, dryRun: false }); + assertEquals(result.errors.length, 0); + assertEquals(result.written.length, 1); + assertEquals(result.written[0], join(tmpDir, ".stackctl")); + + const content = await Deno.readTextFile(join(tmpDir, ".stackctl")); + assertStringIncludes(content, "project:"); + assertStringIncludes(content, "stack:"); + assertStringIncludes(content, "directory:"); + assertStringIncludes(content, "names:"); + assertStringIncludes(content, "network:"); + assertStringIncludes(content, "render:"); + assertStringIncludes(content, "outputDirectory:"); + assertStringIncludes(content, "env:"); + assertStringIncludes(content, "activeName:"); + assertStringIncludes(content, "Generated by `stackctl init`"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: template is valid YAML", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, dryRun: false }); + assertEquals(result.errors.length, 0); + + const content = await Deno.readTextFile(join(tmpDir, ".stackctl")); + // Should parse without throwing + const parsed = parseYaml(content) as Record; + assertEquals(typeof parsed, "object"); + assertEquals(parsed !== null, true); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: dry-run does not write files", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, dryRun: true }); + assertEquals(result.errors.length, 0); + assertEquals(result.written.length, 1); + assertEquals(result.written[0], join(tmpDir, ".stackctl")); + + // Verify file was NOT actually written + try { + await Deno.stat(join(tmpDir, ".stackctl")); + // Should not reach here - file should not exist + assertEquals(true, false, "file should not exist in dry-run mode"); + } catch { + // Expected: file does not exist + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: force overwrites existing file", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + // First write an existing file + const existingPath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile(existingPath, "# existing config"); + + // Without force, should error + const resultNoForce = await initConfig({ cwd: tmpDir, force: false, dryRun: false }); + assertEquals(resultNoForce.errors.length, 1); + assertEquals(resultNoForce.errors[0].includes("already exists"), true); + + // With force, should succeed + const resultForce = await initConfig({ cwd: tmpDir, force: true, dryRun: false }); + assertEquals(resultForce.errors.length, 0); + assertEquals(resultForce.written.length, 1); + + // Verify content was overwritten + const content = await Deno.readTextFile(existingPath); + assertStringIncludes(content, "Generated by `stackctl init`"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: detection finds docker-compose files", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + // Create a docker-compose.yml with services and networks + const composeYml = join(tmpDir, "docker-compose.yml"); + await Deno.writeTextFile( + composeYml, + ` +services: + web: + image: nginx + api: + image: my-api +networks: + frontend: + driver: overlay +`, + ); + + const result = await initConfig({ cwd: tmpDir, detect: true, dryRun: false }); + assertEquals(result.errors.length, 0); + + const content = await Deno.readTextFile(join(tmpDir, ".stackctl")); + // Should have detected service names + assertStringIncludes(content, "web"); + assertStringIncludes(content, "api"); + // Should have detected network + assertStringIncludes(content, "frontend"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: preset minimal produces valid template", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, preset: "minimal", dryRun: false }); + assertEquals(result.errors.length, 0); + assertEquals(result.written.length, 1); + + const content = await Deno.readTextFile(join(tmpDir, ".stackctl")); + // Should be parseable YAML + const parsed = parseYaml(content) as Record; + assertEquals(parsed.project, ""); + assertEquals((parsed.stack as Record).names, ["app"]); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: unknown preset returns error", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, preset: "nonexistent" }); + assertEquals(result.errors.length, 1); + assertEquals(result.errors[0].includes("Unknown preset"), true); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: profile flag writes additional profile file", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + const result = await initConfig({ cwd: tmpDir, profile: "dev", dryRun: false }); + assertEquals(result.errors.length, 0); + assertEquals(result.written.length, 2); + assertEquals(result.written[0], join(tmpDir, ".stackctl")); + assertEquals(result.written[1], join(tmpDir, ".stackctl.dev")); + + // Verify both files exist + await Deno.stat(join(tmpDir, ".stackctl")); + await Deno.stat(join(tmpDir, ".stackctl.dev")); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("initConfig: round-trip: generated YAML is parseable and valid", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-init-test-" }); + try { + // Generate + const result = await initConfig({ cwd: tmpDir, dryRun: false }); + assertEquals(result.errors.length, 0); + + // Parse + const content = await Deno.readTextFile(join(tmpDir, ".stackctl")); + const parsed = parseYaml(content) as Record; + + // Verify key structure + assertEquals(typeof parsed.project, "string"); + assertEquals(typeof parsed.stack, "object"); + assertEquals(typeof parsed.render, "object"); + assertEquals(typeof parsed.env, "object"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); diff --git a/src/config/load.ts b/src/config/load.ts new file mode 100644 index 0000000..9b7822e --- /dev/null +++ b/src/config/load.ts @@ -0,0 +1,302 @@ +/** + * Config file discovery and resolution. + * + * Resolution order (layers merged left to right): + * 1. DEFAULT_CONFIG + * 2. .stackctl (base config) + * 3. .stackctl. (profile overlay) + * 4. .stackctl.local (local overrides) + * 5. .stackctl.local. (local profile overrides) + * + * Validation runs after all layers are merged. + */ +import { exists } from "@std/fs"; +import { parse as parseYaml } from "@std/yaml"; +import { dirname, join } from "@std/path"; +import { DEFAULT_CONFIG } from "./defaults.ts"; +import { mergeConfig } from "./merge.ts"; +import { validateConfig } from "./validate.ts"; +import type { ProfileConfig, ResolvedConfig, StackctlConfig } from "./types.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ResolveOptions { + /** Explicit config file path (bypasses discovery). */ + configPath?: string; + /** Active profile name (from --profile or STACKCTL_PROFILE). */ + profile?: string; + /** Working directory (default: Deno.cwd()). */ + cwd?: string; +} + +export interface DiscoverResult { + /** Absolute path to the discovered .stackctl file. */ + configPath: string; + /** Absolute path to the repository root (parent of .stackctl or .git). */ + repoRoot: string; + /** Path to .stackctl. if it exists. */ + profilePath?: string; + /** Path to .stackctl.local if it exists. */ + localPath?: string; + /** Path to .stackctl.local. if it exists. */ + localProfilePath?: string; +} + +// --------------------------------------------------------------------------- +// Discovery +// --------------------------------------------------------------------------- + +/** + * Walk up from `cwd` looking for `.stackctl`. + * Also detects the repository root via `.git`. + * + * Returns null if no `.stackctl` file can be found. + */ +export async function discoverConfigFiles( + options?: { cwd?: string; profile?: string }, +): Promise { + const cwd = options?.cwd ?? Deno.cwd(); + + const configPath = await walkUpFind(cwd, ".stackctl"); + if (!configPath) return null; + + const baseDir = dirname(configPath); + const repoRoot = await findRepoRoot(cwd, baseDir); + + const result: DiscoverResult = { + configPath, + repoRoot, + }; + + if (options?.profile) { + const profilePath = join(baseDir, `.stackctl.${options.profile}`); + if (await exists(profilePath)) { + result.profilePath = profilePath; + } + } + + const localPath = join(baseDir, ".stackctl.local"); + if (await exists(localPath)) { + result.localPath = localPath; + } + + if (options?.profile) { + const localProfilePath = join(baseDir, `.stackctl.local.${options.profile}`); + if (await exists(localProfilePath)) { + result.localProfilePath = localProfilePath; + } + } + + return result; +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/** + * Full config resolution using the layer merge strategy. + * + * If `configPath` is provided, it takes precedence over automatic discovery. + * Profile, local, and local-profile layers are loaded relative to the base config. + */ +export async function resolveConfig( + options?: ResolveOptions, +): Promise { + let profile = options?.profile ?? Deno.env.get("STACKCTL_PROFILE"); + const cwd = options?.cwd ?? Deno.cwd(); + + // Acquire base config (discovery or explicit path) + let discovery: DiscoverResult | null = null; + + if (options?.configPath) { + const absPath = options.configPath.startsWith("/") + ? options.configPath + : join(cwd, options.configPath); + + discovery = { + configPath: absPath, + repoRoot: dirname(absPath), + }; + + // Also detect sidecar files if they exist + const baseDir = dirname(absPath); + if (profile) { + const profilePath = join(baseDir, `.stackctl.${profile}`); + if (await exists(profilePath)) discovery.profilePath = profilePath; + } + const localPath = join(baseDir, ".stackctl.local"); + if (await exists(localPath)) discovery.localPath = localPath; + if (profile) { + const localProfilePath = join(baseDir, `.stackctl.local.${profile}`); + if (await exists(localProfilePath)) discovery.localProfilePath = localProfilePath; + } + } else { + discovery = await discoverConfigFiles({ cwd, profile }); + if (!discovery) { + throw new Error("No .stackctl config file found. Run `stackctl init` to create one."); + } + } + + // Start with defaults + let merged = { ...DEFAULT_CONFIG } as StackctlConfig; + let profileConfig: ProfileConfig | undefined; + let localConfig: ProfileConfig | undefined; + let localProfileConfig: ProfileConfig | undefined; + + // Layer 2: base config + if (discovery) { + const baseConfig = await loadConfigFile(discovery.configPath); + merged = mergeConfig(merged, baseConfig); + + // Determine profile from defaultProfile if not already set + if (!profile && merged.defaultProfile) { + profile = merged.defaultProfile; + } + + // If we now have a profile, discover .stackctl. in a second pass + if (profile && !discovery.profilePath) { + const baseDir = dirname(discovery.configPath); + const profilePath = join(baseDir, `.stackctl.${profile}`); + if (await exists(profilePath)) { + discovery.profilePath = profilePath; + } + const localProfilePath = join(baseDir, `.stackctl.local.${profile}`); + if (await exists(localProfilePath)) { + discovery.localProfilePath = localProfilePath; + } + } + + // Check for ambiguity: no profile, multiple .stackctl.* files, no defaultProfile + if (!profile) { + const baseDir = dirname(discovery.configPath); + const profileFiles = await findProfileFiles(baseDir); + if (profileFiles.length > 1) { + const names = profileFiles.map((f: string) => f.replace(".stackctl.", "")).join(", "); + throw new Error( + `Ambiguous profile detection: found multiple profile files (${names}). ` + + `Either set a defaultProfile in .stackctl or specify --profile .`, + ); + } else if (profileFiles.length === 1) { + const detected = profileFiles[0].replace(".stackctl.", ""); + profile = detected; + const profilePath = join(baseDir, `.stackctl.${detected}`); + if (await exists(profilePath)) { + discovery.profilePath = profilePath; + } + const localProfilePath = join(baseDir, `.stackctl.local.${detected}`); + if (await exists(localProfilePath)) { + discovery.localProfilePath = localProfilePath; + } + } + } + + // Layer 3: profile + if (discovery.profilePath) { + profileConfig = await loadConfigFile(discovery.profilePath); + merged = mergeConfig(merged, profileConfig); + } + + // Layer 4: local + if (discovery.localPath) { + localConfig = await loadConfigFile(discovery.localPath); + merged = mergeConfig(merged, localConfig); + } + + // Layer 5: local profile + if (discovery.localProfilePath) { + localProfileConfig = await loadConfigFile(discovery.localProfilePath); + merged = mergeConfig(merged, localProfileConfig); + } + } + + // Validate + const errors = validateConfig(merged); + if (errors.length > 0) { + const msg = errors.map((e) => ` ${e.path}: ${e.message}`).join("\n"); + throw new Error(`Config validation failed:\n${msg}`); + } + + return { + base: merged, + profile, + profileConfig, + localConfig, + localProfileConfig, + overrides: [], + }; +} + +// --------------------------------------------------------------------------- +// Simple single-file loader (for testing / external use) +// --------------------------------------------------------------------------- + +/** + * Load and parse a single YAML config file. + * Returns a partial config (no merging, no defaults). + */ +export async function loadConfig(path: string): Promise> { + return await loadConfigFile(path); +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Walk up directory tree looking for a file or directory named `target`. */ +async function walkUpFind(startDir: string, target: string): Promise { + let dir = startDir; + while (true) { + const candidate = join(dir, target); + if (await exists(candidate)) { + return candidate; + } + const parent = dirname(dir); + if (parent === dir) break; // reached filesystem root + dir = parent; + } + return null; +} + +/** Find repo root by looking for .git upwards, falling back to baseDir. */ +async function findProfileFiles(dir: string): Promise { + const profileFiles: string[] = []; + try { + for await (const entry of Deno.readDir(dir)) { + if (!entry.isFile) continue; + const name = entry.name; + if (!name.startsWith(".stackctl.")) continue; + if (name === ".stackctl") continue; + if (name === ".stackctl.local") continue; + if (name.startsWith(".stackctl.local.")) continue; + profileFiles.push(name); + } + } catch { + // Directory unreadable, return empty + } + return profileFiles; +} + +/** Find repo root by looking for .git upwards, falling back to baseDir. */ +async function findRepoRoot(cwd: string, baseDir: string): Promise { + const gitDir = await walkUpFind(cwd, ".git"); + if (gitDir) { + return dirname(gitDir); + } + return baseDir; +} + +/** Read and parse a YAML file, returning a partial config. */ +async function loadConfigFile(path: string): Promise> { + const raw = await Deno.readTextFile(path); + try { + const parsed = parseYaml(raw) as Record; + return (parsed ?? {}) as Partial; + } catch (err: unknown) { + throw new Error( + `Failed to parse YAML config at ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/src/config/load_test.ts b/src/config/load_test.ts new file mode 100644 index 0000000..c017f08 --- /dev/null +++ b/src/config/load_test.ts @@ -0,0 +1,281 @@ +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { discoverConfigFiles, loadConfig, resolveConfig } from "./load.ts"; +import { join } from "@std/path"; + +Deno.test("loadConfig: loads and parses a YAML file", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-load-test-" }); + try { + const configPath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile( + configPath, + ` +project: test-project +stack: + directory: my-stacks + names: + - web + - api + network: my-net +render: + outputDirectory: .out +`, + ); + + const config = await loadConfig(configPath); + assertEquals(config.project, "test-project"); + assertEquals(config.stack?.directory, "my-stacks"); + assertEquals(config.stack?.names, ["web", "api"]); + assertEquals(config.stack?.network, "my-net"); + assertEquals(config.render?.outputDirectory, ".out"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadConfig: empty file returns empty object", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-load-test-" }); + try { + const configPath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile(configPath, ""); + + const config = await loadConfig(configPath); + assertEquals(typeof config, "object"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("loadConfig: invalid YAML throws", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-load-test-" }); + try { + const configPath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile(configPath, "{{ invalid: yaml: :"); + + try { + await loadConfig(configPath); + assertEquals(true, false, "should have thrown"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + assertStringIncludes(msg, "Failed to parse YAML"); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("discoverConfigFiles: finds .stackctl in cwd", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-discover-test-" }); + try { + const configPath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile(configPath, "project: test"); + + const result = await discoverConfigFiles({ cwd: tmpDir }); + assertEquals(result !== null, true); + if (result) { + assertEquals(result.configPath, configPath); + assertEquals(result.repoRoot, tmpDir); + assertEquals(result.profilePath, undefined); + assertEquals(result.localPath, undefined); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("discoverConfigFiles: returns null when no .stackctl found", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-discover-test-" }); + try { + const result = await discoverConfigFiles({ cwd: tmpDir }); + assertEquals(result, null); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("discoverConfigFiles: walks up directory tree", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-discover-test-" }); + try { + // Place .stackctl in parent, cwd in subdir + const basePath = join(tmpDir, ".stackctl"); + await Deno.writeTextFile(basePath, "project: parent"); + const subDir = join(tmpDir, "subdir", "deep"); + await Deno.mkdir(subDir, { recursive: true }); + + const result = await discoverConfigFiles({ cwd: subDir }); + assertEquals(result !== null, true); + if (result) { + assertEquals(result.configPath, basePath); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("discoverConfigFiles: finds profile and local files", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-discover-test-" }); + try { + await Deno.writeTextFile(join(tmpDir, ".stackctl"), "project: test"); + await Deno.writeTextFile(join(tmpDir, ".stackctl.dev"), "project: dev-override"); + await Deno.writeTextFile(join(tmpDir, ".stackctl.local"), "render:\n outputDirectory: .local"); + + const result = await discoverConfigFiles({ cwd: tmpDir, profile: "dev" }); + assertEquals(result !== null, true); + if (result) { + assertEquals(result.profilePath, join(tmpDir, ".stackctl.dev")); + assertEquals(result.localPath, join(tmpDir, ".stackctl.local")); + assertEquals(result.localProfilePath, undefined); // no .stackctl.local.dev + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("discoverConfigFiles: finds local profile file", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-discover-test-" }); + try { + await Deno.writeTextFile(join(tmpDir, ".stackctl"), "project: test"); + await Deno.writeTextFile(join(tmpDir, ".stackctl.local.staging"), "project: staging"); + + const result = await discoverConfigFiles({ cwd: tmpDir, profile: "staging" }); + assertEquals(result !== null, true); + if (result) { + assertEquals(result.localProfilePath, join(tmpDir, ".stackctl.local.staging")); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("resolveConfig: full resolution chain with valid config", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-resolve-test-" }); + try { + await Deno.writeTextFile( + join(tmpDir, ".stackctl"), + ` +project: myproject +stack: + directory: stacks + names: + - web + network: prod-net +render: + outputDirectory: .rendered +`, + ); + + await Deno.writeTextFile( + join(tmpDir, ".stackctl.dev"), + ` +project: myproject-dev +`, + ); + + await Deno.writeTextFile( + join(tmpDir, ".stackctl.local"), + ` +stack: + network: local-net +`, + ); + + const resolved = await resolveConfig({ cwd: tmpDir, profile: "dev" }); + assertEquals(resolved.profile, "dev"); + assertEquals(resolved.base.project, "myproject-dev"); // profile overrides base + assertEquals(resolved.base.stack.network, "local-net"); // local overrides profile + assertEquals(resolved.base.stack.names, ["web"]); + assertEquals(resolved.base.render.outputDirectory, ".rendered"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("resolveConfig: explicit configPath works", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-resolve-test-" }); + try { + const configPath = join(tmpDir, "custom.yaml"); + await Deno.writeTextFile( + configPath, + ` +project: explicit +stack: + directory: out + names: + - svc + network: explicit-net +render: + outputDirectory: .rendered +`, + ); + + const resolved = await resolveConfig({ configPath, cwd: tmpDir }); + assertEquals(resolved.base.project, "explicit"); + assertEquals(resolved.base.stack.network, "explicit-net"); + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("resolveConfig: throws on validation failure", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-resolve-test-" }); + try { + await Deno.writeTextFile( + join(tmpDir, ".stackctl"), + ` +project: "" +stack: + directory: "" + names: [] + network: "" +render: + outputDirectory: "" +`, + ); + + try { + await resolveConfig({ cwd: tmpDir }); + assertEquals(true, false, "should have thrown validation error"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + assertStringIncludes(msg, "Config validation failed"); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); + +Deno.test("resolveConfig: uses STACKCTL_PROFILE env var", async () => { + const tmpDir = await Deno.makeTempDir({ prefix: "stackctl-resolve-test-" }); + try { + await Deno.writeTextFile( + join(tmpDir, ".stackctl"), + ` +project: test +stack: + directory: dir + names: + - app + network: net +render: + outputDirectory: .rendered +`, + ); + + await Deno.writeTextFile( + join(tmpDir, ".stackctl.staging"), + ` +project: staging-project +`, + ); + + Deno.env.set("STACKCTL_PROFILE", "staging"); + try { + const resolved = await resolveConfig({ cwd: tmpDir }); + assertEquals(resolved.profile, "staging"); + assertEquals(resolved.base.project, "staging-project"); + } finally { + Deno.env.delete("STACKCTL_PROFILE"); + } + } finally { + await Deno.remove(tmpDir, { recursive: true }); + } +}); diff --git a/src/config/merge.ts b/src/config/merge.ts new file mode 100644 index 0000000..9644fd0 --- /dev/null +++ b/src/config/merge.ts @@ -0,0 +1,52 @@ +/** + * Deep merge utilities for config layers. + * + * Rules: + * - Objects: recursive merge (inner fields merged, not replaced) + * - Arrays: replacement (not concatenation) + * - Primitives: overlay wins if not undefined + * - undefined in overlay: skipped (does not overwrite) + * - null in overlay: treated as explicit unset + */ + +/** + * Deep-merge an overlay into a base object. Returns a new object. + * Works with any object type — does not require index signatures. + */ +export function mergeConfig(base: T, overlay: Partial): T { + const result: Record = { ...(base as Record) }; + + for (const key of Object.keys(overlay as Record)) { + const overlayVal = (overlay as Record)[key]; + if (overlayVal === undefined) continue; + + const baseVal = result[key]; + + if (isRecord(overlayVal) && isRecord(baseVal)) { + result[key] = mergeConfig( + baseVal as Record, + overlayVal as Record, + ); + } else { + result[key] = overlayVal; + } + } + + return result as T; +} + +/** + * Merge multiple config layers left to right. The first argument is the base. + * Each subsequent argument is a partial overlay merged on top. + */ +export function mergeConfigs(base: T, ...overlays: Partial[]): T { + let result = base; + for (const overlay of overlays) { + result = mergeConfig(result, overlay); + } + return result; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/config/merge_test.ts b/src/config/merge_test.ts new file mode 100644 index 0000000..98b0614 --- /dev/null +++ b/src/config/merge_test.ts @@ -0,0 +1,71 @@ +import { assertEquals } from "@std/assert"; +import { mergeConfig, mergeConfigs } from "./merge.ts"; + +Deno.test("mergeConfig: simple key overlay", () => { + const base = { a: 1, b: 2 }; + const result = mergeConfig(base, { b: 99 }); + assertEquals(result, { a: 1, b: 99 }); +}); + +Deno.test("mergeConfig: nested object merge", () => { + const base: Record = { top: { a: 1, b: 2, deep: { x: 10 } } }; + const overlay: Record = { top: { b: 99, c: 3, deep: { y: 20 } } }; + const result = mergeConfig(base, overlay); + assertEquals(result, { + top: { a: 1, b: 99, c: 3, deep: { x: 10, y: 20 } }, + }); +}); + +Deno.test("mergeConfig: array replacement (not concatenation)", () => { + const base = { items: [1, 2, 3] }; + const result = mergeConfig(base, { items: [4, 5] }); + assertEquals(result, { items: [4, 5] }); +}); + +Deno.test("mergeConfig: undefined in overlay is skipped", () => { + const base: Record = { a: 1, b: 2 }; + const result = mergeConfig(base, { a: undefined, b: 99 }); + assertEquals(result, { a: 1, b: 99 }); +}); + +Deno.test("mergeConfig: null in overlay propagates", () => { + const base: Record = { a: 1, b: "hello" }; + const result = mergeConfig(base, { b: null }); + assertEquals(result, { a: 1, b: null }); +}); + +Deno.test("mergeConfig: partial overlay on empty base", () => { + const base: Record = {}; + const result = mergeConfig(base, { a: 1 }); + assertEquals(result, { a: 1 }); +}); + +Deno.test("mergeConfigs: three-way merge (defaults + base + overlay)", () => { + const defaults = { a: 0, b: 0, c: 0 }; + const result = mergeConfigs(defaults, { a: 1, b: 2 }, { b: 99 }); + assertEquals(result, { a: 1, b: 99, c: 0 }); +}); + +Deno.test("mergeConfigs: single argument returns same shape", () => { + const defaults = { a: 1, b: 2 }; + const result = mergeConfigs(defaults); + assertEquals(result, { a: 1, b: 2 }); +}); + +Deno.test("mergeConfig: adds new keys from overlay", () => { + const base: Record = { existing: true }; + const result = mergeConfig(base, { newKey: "hello" }); + assertEquals(result, { existing: true, newKey: "hello" }); +}); + +Deno.test("mergeConfig: deeply nested merge with arrays replaced", () => { + const base: Record = { + stack: { names: ["old"], network: "old-net" }, + }; + const result = mergeConfig(base, { + stack: { names: ["new1", "new2"] }, + }); + assertEquals(result, { + stack: { names: ["new1", "new2"], network: "old-net" }, + }); +}); diff --git a/src/config/mod.ts b/src/config/mod.ts new file mode 100644 index 0000000..eb848a0 --- /dev/null +++ b/src/config/mod.ts @@ -0,0 +1,10 @@ +/** + * Config module — public API surface. + */ +export * from "./types.ts"; +export * from "./defaults.ts"; +export * from "./merge.ts"; +export * from "./load.ts"; +export * from "./validate.ts"; +export { initConfig } from "./init.ts"; +export type { InitOptions, InitResult } from "./init.ts"; diff --git a/src/config/types.ts b/src/config/types.ts index de9cf37..0223ba2 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -23,6 +23,7 @@ export interface StackctlConfig { secrets?: SecretsConfig; /** Command-specific defaults. */ commands?: CommandsConfig; + overrides?: OverridesConfig; } export interface StackConfig { @@ -82,6 +83,11 @@ export interface ReloadConfig { forceServiceUpdate?: boolean; } +export interface OverridesConfig { + autoDiscoverProfiles?: boolean; + exclude?: string[]; +} + /** A resolved profile configuration — partial config that overlays base config. */ export type ProfileConfig = Partial; diff --git a/src/config/validate.ts b/src/config/validate.ts new file mode 100644 index 0000000..01dd3ad --- /dev/null +++ b/src/config/validate.ts @@ -0,0 +1,83 @@ +/** + * Config validation. + * + * Returns all validation errors at once rather than failing on the first error, + * so users can fix everything in one pass. + */ +import type { StackctlConfig } from "./types.ts"; + +export interface ValidationError { + /** Dot-notation path to the field, e.g. "project", "stack.network". */ + path: string; + /** Human-readable error message. */ + message: string; +} + +/** + * Validate a merged config object. Returns all errors found. + * An empty array indicates a valid config. + */ +export function validateConfig(config: StackctlConfig): ValidationError[] { + const errors: ValidationError[] = []; + + // Required top-level fields + if (!config.project || config.project.trim() === "") { + errors.push({ path: "project", message: "project must be a non-empty string" }); + } + + // Stack sub-fields + if (!config.stack.directory || config.stack.directory.trim() === "") { + errors.push({ + path: "stack.directory", + message: "stack.directory must be a non-empty string", + }); + } + + if (!config.stack.names || config.stack.names.length === 0) { + errors.push({ + path: "stack.names", + message: "stack.names must be a non-empty array (at least one stack name)", + }); + } + + if (!config.stack.network || config.stack.network.trim() === "") { + errors.push({ + path: "stack.network", + message: "stack.network must be a non-empty string", + }); + } + + // Render sub-fields + if (!config.render.outputDirectory || config.render.outputDirectory.trim() === "") { + errors.push({ + path: "render.outputDirectory", + message: "render.outputDirectory must be a non-empty string", + }); + } + + // Env sub-fields + if ( + config.env.activeName !== undefined && + config.env.activeName.trim() === "" + ) { + errors.push({ + path: "env.activeName", + message: "env.activeName must be a non-empty string when set", + }); + } + + // Secrets sub-fields + if (config.secrets) { + if ( + !config.secrets.encryptedFileName || + config.secrets.encryptedFileName.trim() === "" + ) { + errors.push({ + path: "secrets.encryptedFileName", + message: "secrets.encryptedFileName must be a non-empty string", + }); + } + } + + return errors; +} diff --git a/src/config/validate_test.ts b/src/config/validate_test.ts new file mode 100644 index 0000000..b705a3e --- /dev/null +++ b/src/config/validate_test.ts @@ -0,0 +1,112 @@ +import { assertEquals } from "@std/assert"; +import { validateConfig } from "./validate.ts"; +import type { StackctlConfig } from "./types.ts"; + +function makeConfig(overrides?: Partial): StackctlConfig { + const base: StackctlConfig = { + project: "test-project", + stack: { + directory: "stacks", + names: ["web", "api"], + network: "test-net", + }, + render: { + outputDirectory: ".rendered", + }, + env: { + activeName: ".env", + }, + }; + if (overrides) { + // Simple shallow merge for test fixtures + return { + ...base, + ...overrides, + stack: { ...base.stack, ...(overrides.stack ?? {}) }, + render: { ...base.render, ...(overrides.render ?? {}) }, + env: { ...base.env, ...(overrides.env ?? {}) }, + }; + } + return base; +} + +Deno.test("validateConfig: valid config passes", () => { + const config = makeConfig(); + const errors = validateConfig(config); + assertEquals(errors.length, 0); + assertEquals(errors, []); +}); + +Deno.test("validateConfig: missing project returns error", () => { + const config = makeConfig({ project: "" }); + const errors = validateConfig(config); + assertEquals(errors.length, 1); + assertEquals(errors[0].path, "project"); + assertEquals(errors[0].message.includes("project"), true); +}); + +Deno.test("validateConfig: whitespace-only project returns error", () => { + const config = makeConfig({ project: " " }); + const errors = validateConfig(config); + assertEquals(errors.length, 1); + assertEquals(errors[0].path, "project"); +}); + +Deno.test("validateConfig: missing stack.network returns error", () => { + const config = makeConfig({ stack: { directory: "stacks", names: ["web"], network: "" } }); + const errors = validateConfig(config); + assertEquals(errors.length >= 1, true); + assertEquals(errors.some((e) => e.path === "stack.network"), true); +}); + +Deno.test("validateConfig: empty stack.names returns error", () => { + const config = makeConfig({ stack: { directory: "stacks", names: [], network: "net" } }); + const errors = validateConfig(config); + assertEquals(errors.length >= 1, true); + assertEquals(errors.some((e) => e.path === "stack.names"), true); +}); + +Deno.test("validateConfig: missing stack.directory returns error", () => { + const config = makeConfig({ stack: { directory: "", names: ["web"], network: "net" } }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "stack.directory"), true); +}); + +Deno.test("validateConfig: missing render.outputDirectory returns error", () => { + const config = makeConfig({ render: { outputDirectory: "" } }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "render.outputDirectory"), true); +}); + +Deno.test("validateConfig: env.activeName empty returns error", () => { + const config = makeConfig({ env: { activeName: "" } }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "env.activeName"), true); +}); + +Deno.test("validateConfig: env.activeName unset is allowed", () => { + const config = makeConfig({ env: {} }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "env.activeName"), false); +}); + +Deno.test("validateConfig: secrets with empty encryptedFileName returns error", () => { + const config = makeConfig({ secrets: { encryptedFileName: "" } }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "secrets.encryptedFileName"), true); +}); + +Deno.test("validateConfig: secrets with valid encryptedFileName passes", () => { + const config = makeConfig({ secrets: { encryptedFileName: ".env.enc" } }); + const errors = validateConfig(config); + assertEquals(errors.some((e) => e.path === "secrets.encryptedFileName"), false); +}); + +Deno.test("validateConfig: multiple errors returned at once", () => { + const config = makeConfig({ + project: "", + stack: { directory: "", names: [], network: "" }, + }); + const errors = validateConfig(config); + assertEquals(errors.length >= 3, true); +});