import { Command } from "jsr:@cliffy/command@1.0.0-rc.7"; import { Input, Secret, Select } from "jsr:@cliffy/prompt@1.0.0-rc.7"; import * as colors from "jsr:@std/fmt@0.225.2/colors"; import { generateDockerCompose, generateSpireDockerCompose, } from "./compose.ts"; import { AuthSetupConfig, COMPOSE_PATH, DEFAULT_AUTH_CONFIG, ENV_PATH, generateEnv, generateSpireEnv, readEnv, SPIRE_COMPOSE_PATH, SPIRE_ENV_PATH, } from "./env.ts"; import { downloadWorkloadProto, executeBuildImage, executeProtobufCompilation, generateBuildCommands, } from "./build.ts"; export async function generateAuthSetupFiles( config: AuthSetupConfig, ): Promise { const envContent = generateEnv(config); await Deno.writeTextFile(ENV_PATH, envContent); const spireEnvContent = generateSpireEnv(config); await Deno.writeTextFile(SPIRE_ENV_PATH, spireEnvContent); const composeContent = generateDockerCompose(); await Deno.writeTextFile(COMPOSE_PATH, composeContent); const spireComposeContent = generateSpireDockerCompose(); await Deno.writeTextFile(SPIRE_COMPOSE_PATH, spireComposeContent); console.log( colors.green( `\n✓ Successfully generated ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, and ${SPIRE_COMPOSE_PATH}!`, ), ); console.log( colors.green("Setup complete. You may deploy your stacks by running:\n"), ); console.log( colors.cyan( "1. Deploy SPIRE Stack:\n" + " podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n\n" + "2. Deploy Auth-Yes Stack:\n" + " podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n", ), ); } export async function handleAuthSetup( currentConfig: AuthSetupConfig, ): Promise { console.log( colors.gray( "Please provide the following Auth Yes configuration details.\n", ), ); const reg = await Input.prompt({ message: "Enter the Primary Container Registry URL:", default: currentConfig.reg, }); const ghcrReg = await Input.prompt({ message: "Enter the GHCR Mirror Registry URL (for SPIRE):", default: currentConfig.ghcrReg || "ghcr.atyg.org", }); const domainName = await Input.prompt({ message: "Enter the Auth Domain Name:", hint: "E.g., auth.system.local", default: currentConfig.domainName, }); const dbDataPath = await Input.prompt({ message: "Enter the Database Path on the Host:", default: currentConfig.dbDataPath, }); const spireDataPath = await Input.prompt({ message: "Enter the SPIRE Path on the Host:", default: currentConfig.spireDataPath, }); const appSecret = await Secret.prompt({ message: "Enter the App Secret for the IDP:", default: currentConfig.appSecret, minLength: 16, }); const pwdMessage = currentConfig.dbPassword ? "Enter the PostgreSQL database password: (Leave blank to keep existing password)" : "Enter the PostgreSQL database password:"; const pwdInput = await Secret.prompt({ message: pwdMessage, minLength: currentConfig.dbPassword ? 0 : 1, }); const dbPassword = pwdInput === "" && currentConfig.dbPassword !== "" ? currentConfig.dbPassword : pwdInput; const newConfig: AuthSetupConfig = { reg, ghcrReg, domainName, dbPassword, dbDataPath, spireDataPath, appSecret, }; await generateAuthSetupFiles(newConfig); return newConfig; } export async function handleTestConnection(domainName: string): Promise { console.log( colors.bold( colors.blue(`\n=== Testing Auth Connection (https://${domainName}) ===`), ), ); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); const res1 = await fetch(`https://${domainName}/`, { signal: controller.signal, }); clearTimeout(timeoutId); if (res1.status === 502 || res1.status === 503 || res1.status === 504) { throw new Error(`Gateway error: ${res1.status}`); } if (res1.body) { await res1.body.cancel(); } console.log( colors.green( `✓ Auth service is up and reachable at https://${domainName}/`, ), ); } catch (error) { console.log( colors.red( `✗ Error: Could not reach the auth service at https://${domainName}/`, ), ); if (error instanceof Error) { console.log(colors.red(` Reason: ${error.message}`)); } else { console.log(colors.red(` Reason: ${error}`)); } console.log( colors.red( " Please verify the container is running and DNS/Traefik is resolving.", ), ); } } async function handleReviewConfigs(): Promise { const envContent = await Deno.readTextFile(ENV_PATH).catch(() => null); const composeContent = await Deno.readTextFile(COMPOSE_PATH).catch(() => null ); const spireComposeContent = await Deno.readTextFile(SPIRE_COMPOSE_PATH).catch( () => null, ); if (!envContent && !composeContent && !spireComposeContent) { console.log( colors.red("\n✗ No generated configs found. Run the setup first.\n"), ); return; } while (true) { const action = await Select.prompt({ message: "Review Generated Configs", options: [ { name: "[Show .env]", value: "env" }, { name: "[Show compose.yml]", value: "compose" }, { name: "[Show compose.spire.yml]", value: "spire_compose" }, { name: "[Back to Main Menu]", value: "back" }, ], }); if (action === "env") { console.log(colors.bold(colors.blue(`\n=== ${ENV_PATH} ===\n`))); console.log(envContent || colors.yellow("File not found.")); console.log(); } else if (action === "compose") { console.log(colors.bold(colors.blue(`\n=== ${COMPOSE_PATH} ===\n`))); console.log(composeContent || colors.yellow("File not found.")); console.log(); } else if (action === "spire_compose") { console.log( colors.bold(colors.blue(`\n=== ${SPIRE_COMPOSE_PATH} ===\n`)), ); console.log(spireComposeContent || colors.yellow("File not found.")); console.log(); } else if (action === "back") { break; } } } // SIDE EFFECT: Runs the interactive CLI wizard. export async function runSetupWizard(): Promise { console.log(colors.bold(colors.blue("=== Auth Setup Wizard ===\n"))); const loadedEnv = await readEnv(); let currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; if (Object.keys(loadedEnv).length > 0 && currentConfig.domainName) { await handleTestConnection(currentConfig.domainName); console.log(); } while (true) { const action = await Select.prompt({ message: "Main Menu", options: [ { name: "[Test Auth Connection]", value: "test" }, { name: "[Configure Auth Yes API]", value: "auth" }, { name: "[Compile Protobuf Definitions]", value: "compile_proto" }, { name: "[Review Generated Configs]", value: "review" }, { name: "[Build and Push Auth Image]", value: "build" }, { name: "[Exit]", value: "exit" }, ], }); if (action === "test") { await handleTestConnection(currentConfig.domainName); console.log(); } else if (action === "auth") { currentConfig = await handleAuthSetup(currentConfig); } else if (action === "compile_proto") { try { await downloadWorkloadProto(); await executeProtobufCompilation(); console.log(colors.green("\n✓ Successfully compiled protobufs!\n")); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`), ); } else { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error}\n`), ); } } } else if (action === "review") { await handleReviewConfigs(); } else if (action === "build") { try { const commands = generateBuildCommands(currentConfig.reg); await executeBuildImage(commands); console.log(colors.green("\n✓ Successfully built and pushed image!")); console.log( colors.green("You may now deploy your stack by running:\n"), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n", ), ); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Build process failed: ${error.message}\n`), ); } else { console.log(colors.red(`\n✗ Build process failed: ${error}\n`)); } } } else if (action === "exit") { console.log(colors.gray("Exiting...\n")); break; } } } export async function runCli(args = Deno.args): Promise { if (import.meta.main || args.length === 0) { if (!Deno.stdin.isTerminal() && args.length === 0) { console.error( colors.red( "Error: Non-interactive environment detected, but no explicit CLI subcommands or --auto flag were provided. Aborting to prevent hangs.", ), ); Deno.exit(1); } } const cmd = new Command() .name("auth-setup") .description("Auth setup wizard and CLI") .action(() => { runSetupWizard(); }) .command("auth", "Configure Auth Yes API") .option("--auto, --headless", "Run in headless mode") .option("--registry ", "Container Registry URL") .option("--ghcr-registry ", "GHCR Mirror Registry URL") .option("--domain ", "Auth Domain Name") .option("--db-path ", "Database Path on the Host") .option("--spire-path ", "SPIRE Path on the Host") .action(async (options) => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; if (options.auto) { if (!options.registry || !options.domain || !options.dbPath) { console.error( colors.red( "Error: --registry, --domain, and --db-path are required in headless mode.", ), ); Deno.exit(1); } const dbPassword = Deno.env.get("POSTGRES_PASSWORD") || currentConfig.dbPassword; const appSecret = Deno.env.get("APP_SECRET") || currentConfig.appSecret; if (!dbPassword) { console.error( colors.red( "Error: POSTGRES_PASSWORD environment variable is required in headless mode.", ), ); Deno.exit(1); } if (!appSecret) { console.error( colors.red( "Error: APP_SECRET environment variable is required in headless mode.", ), ); Deno.exit(1); } const newConfig: AuthSetupConfig = { reg: options.registry, ghcrReg: options.ghcrRegistry || currentConfig.ghcrReg || "ghcr.atyg.org", domainName: options.domain, dbPassword, dbDataPath: options.dbPath || currentConfig.dbDataPath || "/volume1/docker/auth-yes/data", spireDataPath: options.spirePath || currentConfig.spireDataPath || "/volume1/docker/spire", appSecret, }; await generateAuthSetupFiles(newConfig); } else { if (options.registry) currentConfig.reg = options.registry; if (options.ghcrRegistry) currentConfig.ghcrReg = options.ghcrRegistry; if (options.domain) currentConfig.domainName = options.domain; if (options.dbPath) currentConfig.dbDataPath = options.dbPath; if (options.spirePath) currentConfig.spireDataPath = options.spirePath; await handleAuthSetup(currentConfig); } }) .command("test", "Test Auth Connection") .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; await handleTestConnection(currentConfig.domainName); }) .command("compile_proto", "Compile Protobuf Definitions") .action(async () => { try { await downloadWorkloadProto(); await executeProtobufCompilation(); console.log(colors.green("\n✓ Successfully compiled protobufs!\n")); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error.message}\n`), ); } else { console.log( colors.red(`\n✗ Protobuf compilation failed: ${error}\n`), ); } Deno.exit(1); } }) .command("build", "Build and Push Auth Image") .action(async () => { try { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; const commands = generateBuildCommands(currentConfig.reg); await executeBuildImage(commands); console.log( colors.green("\n✓ Successfully built and pushed auth image!"), ); console.log( colors.green("You may now deploy your stack by running:\n"), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n", ), ); } catch (error) { if (error instanceof Error) { console.log( colors.red(`\n✗ Build process failed: ${error.message}\n`), ); } else { console.log(colors.red(`\n✗ Build process failed: ${error}\n`)); } Deno.exit(1); } }) .command("dump_compose", "Output all generated Compose YAML configurations") .action(() => { console.log( colors.bold( colors.blue( "\n================================================================", ), ), ); console.log( colors.bold( colors.green(" STACK 1: Auth-Yes Stack (infra/compose.yml)"), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateDockerCompose()); console.log( colors.bold( colors.blue( "================================================================", ), ), ); console.log( colors.bold( colors.green( " STACK 2: Standalone SPIRE Stack (infra/compose.spire.yml)", ), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateSpireDockerCompose()); }) .command( "dump_env", "Output current environment configuration (.env and .env.spire)", ) .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; console.log( colors.bold( colors.blue( "\n================================================================", ), ), ); console.log( colors.bold( colors.green(" STACK 1: Auth-Yes Environment (infra/stack.env)"), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateEnv(currentConfig)); console.log( colors.bold( colors.blue( "================================================================", ), ), ); console.log( colors.bold( colors.green( " STACK 2: SPIRE Stack Environment (infra/.env.spire)", ), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); console.log(generateSpireEnv(currentConfig)); }) .command("secrets", "Inspect secrets status and persistence paths") .action(async () => { const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; console.log( colors.bold( colors.blue( "\n=== Auth-Yes Secrets & Persistence Topology ===\n", ), ), ); console.log( `${ colors.cyan("Database Persistence Path:") } ${currentConfig.dbDataPath}`, ); console.log( `${ colors.cyan("SPIRE Persistence Path:") } ${currentConfig.spireDataPath}`, ); console.log( `${ colors.cyan("Local Artifact Files:") } ${ENV_PATH}, ${SPIRE_ENV_PATH}, ${COMPOSE_PATH}, ${SPIRE_COMPOSE_PATH}`, ); console.log( `${colors.cyan("PostgreSQL Password:")} ${ currentConfig.dbPassword ? colors.green( "✓ Configured (len: " + currentConfig.dbPassword.length + ")", ) : colors.red("✗ Missing") }`, ); console.log( `${colors.cyan("Application Secret:")} ${ currentConfig.appSecret ? colors.green( "✓ Configured (len: " + currentConfig.appSecret.length + ")", ) : colors.red("✗ Missing") }`, ); console.log( colors.gray( "\nTip: To dump raw environment variables, run: deno task setup dump_env\n", ), ); }) .command( "release", "Run complete build & release pipeline with copy-paste deployment instructions", ) .action(async () => { console.log( colors.bold( colors.blue( "\n================================================================", ), ), ); console.log( colors.bold( colors.green( " Auth-Yes Infrastructure Build & Release Pipeline", ), ), ); console.log( colors.bold( colors.blue( "================================================================\n", ), ), ); // 1. Quality Gates console.log(colors.cyan("[1/4] Running Quality Gates & Test Suite...")); const testCmd = new Deno.Command("deno", { args: ["task", "test"], stdout: "inherit", stderr: "inherit", }); const testRes = await testCmd.output(); if (testRes.code !== 0) { console.error( colors.red("\n✗ Quality gates failed. Aborting release."), ); Deno.exit(1); } // 2. Compile Protobufs console.log( colors.cyan("\n[2/4] Downloading & Compiling Protobufs..."), ); await downloadWorkloadProto(); await executeProtobufCompilation(); // 3. Build & Push Images console.log( colors.cyan( "\n[3/4] Building and Pushing Custom Container Images...", ), ); const loadedEnv = await readEnv(); const currentConfig: AuthSetupConfig = { ...DEFAULT_AUTH_CONFIG, ...loadedEnv, }; const commands = generateBuildCommands(currentConfig.reg); await executeBuildImage(commands); console.log( colors.green( "\n✓ Successfully built and pushed all stack images to " + currentConfig.reg + "!", ), ); // 4. Output Copy-Paste Guides console.log( colors.cyan( "\n[4/4] Release Complete. Deployment & Configuration Guides:\n", ), ); console.log( colors.bold( colors.yellow( "┌──────────────────────────────────────────────────────────────┐", ), ), ); console.log( colors.bold( colors.yellow( "│ A. FOR NEW SYSTEM INSTALLATIONS │", ), ), ); console.log( colors.bold( colors.yellow( "└──────────────────────────────────────────────────────────────┘", ), ), ); console.log( colors.gray("# 1. Inspect environment variables and secrets:"), ); console.log(colors.cyan("deno task setup dump_env")); console.log( colors.gray("\n# 2. Inspect generated Docker Compose files:"), ); console.log(colors.cyan("deno task setup dump_compose")); console.log( colors.gray("\n# 3. Create persistent storage paths on host:"), ); console.log( colors.cyan( `mkdir -p ${currentConfig.spireDataPath} ${currentConfig.dbDataPath}`, ), ); console.log(colors.gray("\n# 4. Deploy fresh stacks:")); console.log( colors.cyan( "podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d", ), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d", ), ); console.log( colors.bold( colors.yellow( "\n┌──────────────────────────────────────────────────────────────┐", ), ), ); console.log( colors.bold( colors.yellow( "│ B. FOR EXISTING SYSTEM UPDATES (ROLLING RESTART) │", ), ), ); console.log( colors.bold( colors.yellow( "└──────────────────────────────────────────────────────────────┘", ), ), ); console.log( colors.gray( "# Pull newly pushed custom images and restart containers:", ), ); console.log( colors.cyan( "podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml pull", ), ); console.log( colors.cyan( "podman-compose --project-name spire --env-file infra/.env.spire -f infra/compose.spire.yml up -d\n", ), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml pull", ), ); console.log( colors.cyan( "podman-compose --project-name auth-yes --env-file infra/stack.env -f infra/compose.yml up -d\n", ), ); }); await cmd.parse(args); }