Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

135 changes: 128 additions & 7 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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:string>", "Path to .stackctl config file.", { hidden: false });

// Default action: show help when no subcommand matches
cli.action(() => {
Expand All @@ -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<string, unknown>) => {
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) ---
Expand All @@ -56,9 +100,54 @@ export function buildCli(): Command {
.option("--stacks <names:string>", "Comma-separated list of stack names to generate.")
.option("--output-dir <path:string>", "Write generated stacks to a specific directory.")
.option("--profile <name:string>", "Use a specific profile.")
.action(() => {
console.error("generate: not yet implemented (issue #4)");
Deno.exit(1);
.action(async (options: Record<string, unknown>) => {
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) ---
Expand Down Expand Up @@ -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<void> {
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}`);
}
12 changes: 6 additions & 6 deletions src/cli/mod_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
110 changes: 110 additions & 0 deletions src/compose/discover.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
/** 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<DiscoverResult> {
const stacks: Record<string, string[]> = {};
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<string, unknown> | 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<string>): 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;
}
Loading