From de3132de9a87192f345b1f98f680274d43679aa2 Mon Sep 17 00:00:00 2001 From: default Date: Mon, 11 May 2026 19:28:59 +0000 Subject: [PATCH 01/23] feat: stack commands --- cli/src/commands/init.ts | 46 +++- cli/src/common/config.ts | 59 ++++ cli/src/index.ts | 2 + cli/src/modules/stack/commands/down.ts | 43 +++ cli/src/modules/stack/commands/logs.ts | 32 +++ cli/src/modules/stack/commands/restart.ts | 42 +++ cli/src/modules/stack/commands/status.ts | 34 +++ cli/src/modules/stack/commands/up.ts | 85 ++++++ cli/src/modules/stack/index.ts | 76 ++++++ cli/src/modules/stack/services/compose.ts | 185 +++++++++++++ .../modules/stack/services/docker-compose.ts | 210 ++++++++++++++ cli/src/modules/stack/services/health.ts | 123 +++++++++ cli/src/modules/stack/types/config.ts | 121 ++++++++ cli/src/modules/stack/types/index.ts | 15 + cli/src/modules/stack/utils/stack-config.ts | 258 ++++++++++++++++++ 15 files changed, 1323 insertions(+), 8 deletions(-) create mode 100644 cli/src/modules/stack/commands/down.ts create mode 100644 cli/src/modules/stack/commands/logs.ts create mode 100644 cli/src/modules/stack/commands/restart.ts create mode 100644 cli/src/modules/stack/commands/status.ts create mode 100644 cli/src/modules/stack/commands/up.ts create mode 100644 cli/src/modules/stack/index.ts create mode 100644 cli/src/modules/stack/services/compose.ts create mode 100644 cli/src/modules/stack/services/docker-compose.ts create mode 100644 cli/src/modules/stack/services/health.ts create mode 100644 cli/src/modules/stack/types/config.ts create mode 100644 cli/src/modules/stack/types/index.ts create mode 100644 cli/src/modules/stack/utils/stack-config.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 9045262..13ad01b 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -12,6 +12,7 @@ import { getSecretsFilePath, getPostkitDir, getPostkitAuthDir, + getStackDir, } from "../common/config"; import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; @@ -24,6 +25,7 @@ const GITIGNORE_ENTRIES = [ ".postkit/db/plan.sql", ".postkit/db/schema.sql", ".postkit/db/session/", + ".postkit/stack/", "postkit.secrets.json", ]; @@ -36,6 +38,7 @@ const SCAFFOLD_PUBLIC_CONFIG: PostkitPublicConfig = { auth: { configCliImage: "adorsys/keycloak-config-cli:6.4.0-24", }, + stack: {}, }; // Sensitive credentials — gitignored @@ -57,6 +60,7 @@ const SCAFFOLD_SECRETS: PostkitSecrets = { adminPass: "", }, }, + stack: {}, }; // Example secrets template committed alongside the public config @@ -82,6 +86,19 @@ const SCAFFOLD_SECRETS_EXAMPLE: PostkitSecrets = { adminPass: "changeme", }, }, + stack: { + postgres: { + user: "postgres", + password: "changeme", + }, + keycloak: { + adminUser: "admin", + adminPassword: "changeme", + }, + postgrest: { + jwtSecret: "changeme-to-a-long-random-secret", + }, + }, }; export async function initCommand(options: CommandOptions): Promise { @@ -111,7 +128,7 @@ export async function initCommand(options: CommandOptions): Promise { } } - const totalSteps = 5; + const totalSteps = 6; // Step 1: Create .postkit/db/ directory logger.step(1, totalSteps, "Creating .postkit/db/ directory"); @@ -158,8 +175,19 @@ export async function initCommand(options: CommandOptions): Promise { spinner.succeed(".postkit/auth/ directory created"); } - // Step 3: Generate config and secrets files - logger.step(3, totalSteps, "Generating config and secrets files"); + // Step 3: Create .postkit/stack/ directory + logger.step(3, totalSteps, "Creating .postkit/stack/ directory"); + if (options.dryRun) { + logger.info(`Dry run: would create ${POSTKIT_DIR}/stack/`); + } else { + const spinner = ora("Creating .postkit/stack/ directory...").start(); + const stackDir = getStackDir(); + fs.mkdirSync(stackDir, {recursive: true}); + spinner.succeed(".postkit/stack/ directory created"); + } + + // Step 4: Generate config and secrets files + logger.step(4, totalSteps, "Generating config and secrets files"); if (options.dryRun) { logger.info(`Dry run: would create ${POSTKIT_CONFIG_FILE} (committed) and ${POSTKIT_SECRETS_FILE} (gitignored)`); } else { @@ -177,8 +205,8 @@ export async function initCommand(options: CommandOptions): Promise { spinner.succeed(`${POSTKIT_CONFIG_FILE}, ${POSTKIT_SECRETS_FILE}, and postkit.secrets.example.json created`); } - // Step 4: Update .gitignore - logger.step(4, totalSteps, "Updating .gitignore"); + // Step 5: Update .gitignore + logger.step(5, totalSteps, "Updating .gitignore"); const gitignorePath = path.join(projectRoot, ".gitignore"); if (options.dryRun) { logger.info("Dry run: would update .gitignore with Postkit entries"); @@ -205,8 +233,8 @@ export async function initCommand(options: CommandOptions): Promise { } } - // Step 5: Summary - logger.step(5, totalSteps, "Done"); + // Step 6: Summary + logger.step(6, totalSteps, "Done"); logger.blank(); logger.success("Postkit project initialized!"); logger.blank(); @@ -228,5 +256,7 @@ export async function initCommand(options: CommandOptions): Promise { logger.info(` 1. Fill in ${POSTKIT_SECRETS_FILE} with your database credentials`); logger.info(" 2. Add remote databases:"); logger.info(" postkit db remote add staging \"postgres://...\""); - logger.info(" 3. Run postkit db start to begin a migration session"); + logger.info(" 3. Start the local backend stack:"); + logger.info(" postkit stack up"); + logger.info(" 4. Or run postkit db start to begin a migration session"); } diff --git a/cli/src/common/config.ts b/cli/src/common/config.ts index fcc631c..6d423a4 100644 --- a/cli/src/common/config.ts +++ b/cli/src/common/config.ts @@ -36,6 +36,10 @@ export function getPostkitAuthDir(): string { return path.join(projectRoot, POSTKIT_DIR, "auth"); } +export function getStackDir(): string { + return path.join(projectRoot, POSTKIT_DIR, "stack"); +} + export function getVendorDir(): string { return path.join(cliRoot, "vendor"); } @@ -76,9 +80,42 @@ export interface AuthPublicConfig { configCliImage?: string; } +export interface StackPostgresPublicConfig { + enabled?: boolean; + port?: number; + pgVersion?: number; + image?: string; + database?: string; + volume?: string; +} + +export interface StackKeycloakPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + realm?: string; + volume?: string; +} + +export interface StackPostgrestPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + dbSchema?: string; + dbAnonRole?: string; +} + +export interface StackPublicConfig { + postgres?: StackPostgresPublicConfig; + keycloak?: StackKeycloakPublicConfig; + postgrest?: StackPostgrestPublicConfig; + network?: string; +} + export interface PostkitPublicConfig { db?: DbPublicConfig; auth?: AuthPublicConfig; + stack?: StackPublicConfig; } // ─── Secrets (gitignored) ───────────────────────────────────────────────────── @@ -101,9 +138,30 @@ export interface AuthSecretsConfig { target?: Partial; } +export interface StackPostgresSecrets { + user?: string; + password?: string; +} + +export interface StackKeycloakSecrets { + adminUser?: string; + adminPassword?: string; +} + +export interface StackPostgrestSecrets { + jwtSecret?: string; +} + +export interface StackSecretsConfig { + postgres?: StackPostgresSecrets; + keycloak?: StackKeycloakSecrets; + postgrest?: StackPostgrestSecrets; +} + export interface PostkitSecrets { db?: DbSecretsConfig; auth?: AuthSecretsConfig; + stack?: StackSecretsConfig; } // ─── Merged runtime config ──────────────────────────────────────────────────── @@ -112,6 +170,7 @@ export interface PostkitSecrets { export interface PostkitConfig { db: DbInputConfig; auth: AuthInputConfig; + stack?: Record; } let cachedConfig: PostkitConfig | null = null; diff --git a/cli/src/index.ts b/cli/src/index.ts index d12a61b..d7b5877 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -3,6 +3,7 @@ import {createRequire} from "module"; import {initCommand} from "./commands/init"; import {registerDbModule} from "./modules/db/index"; import {registerAuthModule} from "./modules/auth/index"; +import {registerStackModule} from "./modules/stack/index"; import {logger} from "./common/logger"; const require = createRequire(import.meta.url); @@ -41,6 +42,7 @@ program // Register modules registerDbModule(program); registerAuthModule(program); +registerStackModule(program); // Parse and run program.parse(); diff --git a/cli/src/modules/stack/commands/down.ts b/cli/src/modules/stack/commands/down.ts new file mode 100644 index 0000000..1f7ebeb --- /dev/null +++ b/cli/src/modules/stack/commands/down.ts @@ -0,0 +1,43 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeDown} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export interface DownOptions extends CommandOptions { + volumes?: boolean; +} + +export async function downCommand(options: DownOptions): Promise { + logger.heading("PostKit Stack Down"); + + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const spinner = ora("Stopping stack services...").start(); + const result = await composeDown(composeFile, {volumes: options.volumes}); + + if (result.exitCode !== 0) { + spinner.fail("Failed to stop services"); + logger.error(result.stderr); + return; + } + + spinner.succeed(options.volumes + ? "Stack stopped and volumes removed" + : "Stack stopped", + ); + + logger.blank(); + logger.info("Containers removed. Data preserved in Docker volumes."); + if (!options.volumes) { + logger.info("Use --volumes to remove persistent data as well."); + } +} diff --git a/cli/src/modules/stack/commands/logs.ts b/cli/src/modules/stack/commands/logs.ts new file mode 100644 index 0000000..24f817a --- /dev/null +++ b/cli/src/modules/stack/commands/logs.ts @@ -0,0 +1,32 @@ +import fs from "fs"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeLogs} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export interface LogsOptions extends CommandOptions { + follow?: boolean; + tail?: string; +} + +export async function logsCommand( + options: LogsOptions, + service?: string, +): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const follow = options.follow !== false; + const tail = options.tail ? parseInt(options.tail, 10) : 100; + + logger.info(`Showing logs${service ? ` for ${service}` : ""}...`); + logger.blank(); + + await composeLogs(composeFile, service, {follow, tail}); +} diff --git a/cli/src/modules/stack/commands/restart.ts b/cli/src/modules/stack/commands/restart.ts new file mode 100644 index 0000000..17d19e2 --- /dev/null +++ b/cli/src/modules/stack/commands/restart.ts @@ -0,0 +1,42 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath, getStackConfig} from "../utils/stack-config"; +import {composeRestart} from "../services/docker-compose"; +import {waitForAllServices} from "../services/health"; +import {PostkitError} from "../../../common/errors"; + +export async function restartCommand( + options: CommandOptions, + service?: string, +): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const label = service ?? "all services"; + const spinner = ora(`Restarting ${label}...`).start(); + + const result = await composeRestart(composeFile, service); + + if (result.exitCode !== 0) { + spinner.fail(`Failed to restart ${label}`); + logger.error(result.stderr); + return; + } + + // Health check the restarted services + const config = getStackConfig(); + const services = service ? [service] : ["postgres", "keycloak", "postgrest"]; + try { + await waitForAllServices(config, services, spinner); + spinner.succeed(`${label} restarted and healthy`); + } catch { + spinner.warn(`${label} restarted but may still be starting`); + } +} diff --git a/cli/src/modules/stack/commands/status.ts b/cli/src/modules/stack/commands/status.ts new file mode 100644 index 0000000..7cf3cfb --- /dev/null +++ b/cli/src/modules/stack/commands/status.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getComposeFilePath} from "../utils/stack-config"; +import {composeStatus} from "../services/docker-compose"; +import {PostkitError} from "../../../common/errors"; + +export async function statusCommand(options: CommandOptions): Promise { + const composeFile = getComposeFilePath(); + if (!fs.existsSync(composeFile)) { + throw new PostkitError( + "No stack found.", + "Run 'postkit stack up' first to start the stack.", + ); + } + + const services = await composeStatus(composeFile); + + if (options.json) { + console.log(JSON.stringify(services, null, 2)); + return; + } + + if (services.length === 0) { + logger.warn("No running services found. Run 'postkit stack up' to start the stack."); + return; + } + + logger.heading("PostKit Stack Status"); + logger.table( + ["Service", "Container", "State", "Health", "Ports"], + services.map((s) => [s.service, s.name, s.state, s.health, s.ports]), + ); +} diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts new file mode 100644 index 0000000..9bde06c --- /dev/null +++ b/cli/src/modules/stack/commands/up.ts @@ -0,0 +1,85 @@ +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig, ensureStackSecrets, getComposeFilePath} from "../utils/stack-config"; +import {checkDockerComposeAvailable, composeUp} from "../services/docker-compose"; +import {writeComposeFile, getSelectedServices} from "../services/compose"; +import {waitForAllServices} from "../services/health"; + +export interface UpOptions extends CommandOptions { + wait?: boolean; +} + +export async function upCommand( + options: UpOptions, + services: string[] = [], +): Promise { + logger.heading("PostKit Stack Up"); + + // Step 1: Check Docker + Compose availability + const spinner = ora("Checking Docker...").start(); + await checkDockerComposeAvailable(); + spinner.succeed("Docker and Docker Compose available"); + + // Step 2: Load config and ensure secrets + let config = getStackConfig(); + config = ensureStackSecrets(config); + + // Step 3: Resolve which services to start + const selected = getSelectedServices(config, services); + const serviceList = selected.join(", "); + + // Step 4: Generate compose file + const composeSpinner = ora(`Generating docker-compose.yml for: ${serviceList}`).start(); + const composeFile = writeComposeFile(config, selected); + composeSpinner.succeed(`Compose file written to .postkit/stack/docker-compose.yml`); + + // Step 5: Start services + const upSpinner = ora(`Starting services: ${serviceList}`).start(); + const result = await composeUp(composeFile, selected); + + if (result.exitCode !== 0) { + upSpinner.fail("Failed to start services"); + logger.error(result.stderr); + logger.info("Run 'postkit stack logs' for details."); + return; + } + upSpinner.succeed(`Services started: ${serviceList}`); + + // Step 6: Health checks + if (options.wait !== false) { + const healthSpinner = ora("Waiting for services to become healthy...").start(); + try { + await waitForAllServices(config, selected, healthSpinner); + healthSpinner.succeed("All services healthy"); + } catch (error) { + healthSpinner.warn(String((error as Error).message)); + logger.warn("Some services may still be starting. Check with 'postkit stack status'."); + } + } + + // Step 7: Print summary + logger.blank(); + logger.success("Stack is running!"); + logger.blank(); + logger.table( + ["Service", "URL", "Port"], + selected.map((s) => { + switch (s) { + case "postgres": + return ["PostgreSQL", `postgres://${config.postgres.user}:***@localhost:${config.postgres.port}/${config.postgres.database}`, String(config.postgres.port)]; + case "keycloak": + return ["Keycloak", `http://localhost:${config.keycloak.port}`, String(config.keycloak.port)]; + case "postgrest": + return ["PostgREST", `http://localhost:${config.postgrest.port}`, String(config.postgrest.port)]; + default: + return [s, "", ""]; + } + }), + ); + logger.blank(); + logger.info("Useful commands:"); + logger.info(" postkit stack status — Check service health"); + logger.info(" postkit stack logs — Tail service logs"); + logger.info(" postkit stack down — Stop all services"); +} diff --git a/cli/src/modules/stack/index.ts b/cli/src/modules/stack/index.ts new file mode 100644 index 0000000..4ca2f9d --- /dev/null +++ b/cli/src/modules/stack/index.ts @@ -0,0 +1,76 @@ +import {Command} from "commander"; +import {withInitCheck} from "../../common/init-check"; +import {upCommand} from "./commands/up"; +import {downCommand} from "./commands/down"; +import {statusCommand} from "./commands/status"; +import {logsCommand} from "./commands/logs"; +import {restartCommand} from "./commands/restart"; + +export function registerStackModule(program: Command): void { + const stack = program + .command("stack") + .description("Manage local backend service stack"); + + // Up command + stack + .command("up") + .description("Start all or selected backend services") + .argument("[services...]", "Services to start (postgres, keycloak, postgrest)") + .option("--no-wait", "Skip health check waiting") + .action(async (services: string[], cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await upCommand(options as never, services); + }); + }); + + // Down command + stack + .command("down") + .description("Stop and remove all stack containers") + .option("--volumes", "Remove persistent volumes too") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await downCommand(options as never); + }); + }); + + // Status command + stack + .command("status") + .description("Show running services, ports, and health") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await statusCommand(options as never); + }); + }); + + // Logs command + stack + .command("logs") + .description("Tail logs for stack services") + .argument("[service]", "Service name to tail (omit for all)") + .option("-f, --follow", "Follow log output (default: true)") + .option("--no-follow", "Don't follow log output") + .option("-n, --tail ", "Number of lines to show", "100") + .action(async (service: string | undefined, cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await logsCommand(options as never, service); + }); + }); + + // Restart command + stack + .command("restart") + .description("Restart a stack service") + .argument("[service]", "Service name to restart (omit for all)") + .action(async (service: string | undefined, cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await restartCommand(options as never, service); + }); + }); +} diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts new file mode 100644 index 0000000..c2544de --- /dev/null +++ b/cli/src/modules/stack/services/compose.ts @@ -0,0 +1,185 @@ +import fs from "fs"; +import path from "path"; +import type {StackConfig} from "../types/config"; +import {getStackDir} from "../utils/stack-config"; + +/** All supported service names. */ +export const ALL_SERVICES = ["postgres", "keycloak", "postgrest"] as const; +export type ServiceName = (typeof ALL_SERVICES)[number]; + +/** + * Resolve which services to start based on user selection. + * Always includes postgres if keycloak or postgrest are selected (dependency). + */ +export function getSelectedServices( + config: StackConfig, + requested: string[], +): ServiceName[] { + // Validate requested names + const valid = new Set(ALL_SERVICES); + for (const name of requested) { + if (!valid.has(name)) { + throw new Error( + `Unknown service: "${name}". Available services: ${ALL_SERVICES.join(", ")}`, + ); + } + } + + // If none specified, use all enabled services + const selected = requested.length > 0 + ? requested + : ALL_SERVICES.filter((s) => { + const svc = config[s as keyof StackConfig]; + return typeof svc === "object" && "enabled" in svc ? svc.enabled : true; + }); + + // Always include postgres if keycloak or postgrest are selected + const set = new Set(selected as ServiceName[]); + if (set.has("keycloak") || set.has("postgrest")) { + set.add("postgres"); + } + + return Array.from(set); +} + +/** + * Generate a docker-compose.yml string from the resolved config. + */ +export function generateComposeFile( + config: StackConfig, + services: ServiceName[], +): string { + const sections: string[] = ["services:"]; + + if (services.includes("postgres")) { + sections.push(renderPostgres(config)); + } + + if (services.includes("keycloak")) { + sections.push(renderKeycloak(config)); + } + + if (services.includes("postgrest")) { + sections.push(renderPostgrest(config)); + } + + // Network + sections.push(` +networks: + ${config.network}: + driver: bridge +`); + + // Volumes + const volumes: string[] = []; + if (services.includes("postgres")) { + volumes.push(` ${config.postgres.volume}:`); + } + if (services.includes("keycloak")) { + volumes.push(` ${config.keycloak.volume}:`); + } + if (volumes.length > 0) { + sections.push("volumes:\n" + volumes.join("\n") + "\n"); + } + + return sections.join("\n") + "\n"; +} + +/** + * Write the compose file to .postkit/stack/docker-compose.yml. + * Returns the file path. + */ +export function writeComposeFile( + config: StackConfig, + services: ServiceName[], +): string { + const stackDir = getStackDir(); + fs.mkdirSync(stackDir, {recursive: true}); + + const content = generateComposeFile(config, services); + const filePath = path.join(stackDir, "docker-compose.yml"); + fs.writeFileSync(filePath, content, "utf-8"); + return filePath; +} + +// ============================================ +// Service Renderers +// ============================================ + +function renderPostgres(config: StackConfig): string { + const pg = config.postgres; + const image = pg.image.replace("${pgVersion}", String(pg.pgVersion)); + return ` + postgres: + image: ${image} + container_name: postkit-postgres + ports: + - "${pg.port}:5432" + environment: + POSTGRES_USER: ${pg.user} + POSTGRES_PASSWORD: ${pg.password} + POSTGRES_DB: ${pg.database} + volumes: + - ${pg.volume}:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${pg.user}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - ${config.network} +`; +} + +function renderKeycloak(config: StackConfig): string { + const kc = config.keycloak; + const pg = config.postgres; + return ` + keycloak: + image: ${kc.image} + container_name: postkit-keycloak + command: start-dev + ports: + - "${kc.port}:8080" + environment: + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/${pg.database} + KC_DB_USERNAME: ${pg.user} + KC_DB_PASSWORD: ${pg.password} + KEYCLOAK_ADMIN: ${kc.adminUser} + KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8080/ -o /dev/null || exit 1"] + interval: 10s + timeout: 5s + retries: 15 + start_period: 45s + networks: + - ${config.network} +`; +} + +function renderPostgrest(config: StackConfig): string { + const pr = config.postgrest; + const pg = config.postgres; + return ` + postgrest: + image: ${pr.image} + container_name: postkit-postgrest + ports: + - "${pr.port}:3000" + environment: + PGRST_DB_URI: postgres://${pg.user}:${pg.password}@postgres:5432/${pg.database} + PGRST_DB_SCHEMAS: ${pr.dbSchema} + PGRST_DB_ANON_ROLE: ${pr.dbAnonRole} + PGRST_JWT_SECRET: ${pr.jwtSecret} + depends_on: + postgres: + condition: service_healthy + networks: + - ${config.network} +`; +} diff --git a/cli/src/modules/stack/services/docker-compose.ts b/cli/src/modules/stack/services/docker-compose.ts new file mode 100644 index 0000000..03595bc --- /dev/null +++ b/cli/src/modules/stack/services/docker-compose.ts @@ -0,0 +1,210 @@ +import {spawn} from "child_process"; +import {commandExists, runCommand} from "../../../common/shell"; +import type {ShellResult} from "../../../common/types"; +import {PostkitError} from "../../../common/errors"; +import type {ServiceStatus} from "../types/config"; + +/** + * Verify Docker and Docker Compose v2 are available. + */ +export async function checkDockerComposeAvailable(): Promise { + const installed = await commandExists("docker"); + if (!installed) { + throw new PostkitError( + "Docker not found.", + "Install Docker Desktop from https://docker.com to use stack commands.", + ); + } + + const result = await runCommand("docker info --format '{{.}}'", {timeout: 10000}); + if (result.exitCode !== 0) { + throw new PostkitError( + "Docker is not running.", + "Start Docker Desktop and retry.", + ); + } + + const composeResult = await runCommand("docker compose version", {timeout: 10000}); + if (composeResult.exitCode !== 0) { + throw new PostkitError( + "Docker Compose V2 is not available.", + "Update Docker Desktop to get Docker Compose V2 (included by default).", + ); + } +} + +/** + * Run `docker compose up -d` for selected services. + */ +export async function composeUp( + composeFile: string, + services: string[], +): Promise { + const args = ["compose", "-f", composeFile, "up", "-d", ...services]; + return runDockerCompose(args); +} + +/** + * Run `docker compose down` optionally removing volumes. + */ +export async function composeDown( + composeFile: string, + options?: {volumes?: boolean}, +): Promise { + const args = ["compose", "-f", composeFile, "down"]; + if (options?.volumes) { + args.push("--volumes"); + } + return runDockerCompose(args); +} + +/** + * Run `docker compose ps --format json` and parse the result. + */ +export async function composeStatus( + composeFile: string, +): Promise { + const result = await runDockerCompose([ + "compose", "-f", composeFile, "ps", "--format", "json", + ]); + + if (result.exitCode !== 0) { + return []; + } + + return parseComposeStatus(result.stdout); +} + +/** + * Stream logs from docker compose. For follow mode, spawns a child process + * that pipes directly to stdout/stderr (runs until Ctrl+C). + * For non-follow mode, collects and returns. + */ +export async function composeLogs( + composeFile: string, + service?: string, + options?: {follow?: boolean; tail?: number}, +): Promise { + const args = ["compose", "-f", composeFile, "logs"]; + if (options?.tail) { + args.push("--tail", String(options.tail)); + } + if (options?.follow !== false) { + args.push("--follow"); + } + if (service) { + args.push(service); + } + + return new Promise((resolve) => { + const child = spawn("docker", args, { + stdio: ["ignore", "inherit", "inherit"], + }); + + child.on("close", () => resolve()); + child.on("error", () => resolve()); + }); +} + +/** + * Restart a specific service or all services. + */ +export async function composeRestart( + composeFile: string, + service?: string, +): Promise { + const args = ["compose", "-f", composeFile, "restart"]; + if (service) { + args.push(service); + } + return runDockerCompose(args); +} + +/** + * Parse `docker compose ps --format json` output into ServiceStatus[]. + * Docker Compose v2 outputs one JSON object per line (NDJSON). + */ +export function parseComposeStatus(output: string): ServiceStatus[] { + const statuses: ServiceStatus[] = []; + + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const obj = JSON.parse(trimmed); + // Docker Compose v2 format + const health = obj.Health ?? obj.HealthStatus ?? ""; + const ports = obj.Publishers ?? obj.Ports ?? []; + const port = Array.isArray(ports) && ports.length > 0 + ? (ports[0] as Record).PublishedPort ?? (ports[0] as Record).PublicPort ?? null + : null; + + statuses.push({ + name: obj.Name ?? obj.Names ?? "", + service: obj.Service ?? obj.Labels?.["com.docker.compose.service"] ?? "", + state: obj.State ?? obj.Status ?? "", + health: typeof health === "string" ? health : "", + ports: formatPorts(ports), + publisherPort: typeof port === "number" ? port : null, + }); + } catch { + // Skip unparseable lines + } + } + + return statuses; +} + +// ============================================ +// Internal Helpers +// ============================================ + +function runDockerCompose(args: string[]): Promise { + return new Promise((resolve) => { + const child = spawn("docker", args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + resolve({ + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + }); + }); + + child.on("error", (error) => { + resolve({ + stdout: "", + stderr: error.message, + exitCode: 1, + }); + }); + }); +} + +function formatPorts(ports: unknown): string { + if (!Array.isArray(ports)) return ""; + return ports + .map((p: Record) => { + const pub = p.PublishedPort ?? p.PublicPort; + const priv = p.TargetPort ?? p.PrivatePort; + if (pub && priv) return `${pub}:${priv}`; + if (priv) return String(priv); + return ""; + }) + .filter(Boolean) + .join(", "); +} diff --git a/cli/src/modules/stack/services/health.ts b/cli/src/modules/stack/services/health.ts new file mode 100644 index 0000000..731e2b5 --- /dev/null +++ b/cli/src/modules/stack/services/health.ts @@ -0,0 +1,123 @@ +import http from "http"; +import net from "net"; +import type {Ora} from "ora"; +import type {StackConfig} from "../types/config"; + +const DEFAULT_MAX_ATTEMPTS = 60; +const RETRY_DELAY_MS = 2000; + +/** + * Wait for a TCP connection to become available (used for PostgreSQL). + */ +export async function waitForPostgres( + host: string, + port: number, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + if (await isTcpReachable(host, port)) return; + await sleep(RETRY_DELAY_MS); + } + throw new Error(`PostgreSQL at ${host}:${port} did not become ready within ${maxAttempts * RETRY_DELAY_MS / 1000}s`); +} + +/** + * Wait for an HTTP health endpoint to return 2xx. + */ +export async function waitForHttp( + url: string, + serviceName: string, + maxAttempts = DEFAULT_MAX_ATTEMPTS, +): Promise { + for (let i = 0; i < maxAttempts; i++) { + try { + const ok = await httpGetOk(url); + if (ok) return; + } catch { + // Connection refused / reset — service not up yet + } + await sleep(RETRY_DELAY_MS); + } + throw new Error(`${serviceName} at ${url} did not become ready within ${maxAttempts * RETRY_DELAY_MS / 1000}s`); +} + +/** + * Wait for all started services to become healthy. + * Updates the spinner with progress as services become ready. + */ +export async function waitForAllServices( + config: StackConfig, + services: string[], + spinner: Ora, +): Promise { + const checks: Promise[] = []; + + for (const service of services) { + switch (service) { + case "postgres": { + const check = waitForPostgres("localhost", config.postgres.port) + .then(() => { spinner.text = `${spinner.text} (postgres ready)`; }); + checks.push(check); + break; + } + case "keycloak": { + const url = `http://localhost:${config.keycloak.port}/`; + const check = waitForHttp(url, "Keycloak") + .then(() => { spinner.text = `${spinner.text} (keycloak ready)`; }); + checks.push(check); + break; + } + case "postgrest": { + const url = `http://localhost:${config.postgrest.port}/`; + const check = waitForHttp(url, "PostgREST") + .then(() => { spinner.text = `${spinner.text} (postgrest ready)`; }); + checks.push(check); + break; + } + } + } + + await Promise.all(checks); +} + +// ============================================ +// Internal Helpers +// ============================================ + +function isTcpReachable(host: string, port: number): Promise { + return new Promise((resolve) => { + const socket = net.createConnection({host, port}); + socket.setTimeout(2000); + socket.on("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.on("error", () => { + socket.destroy(); + resolve(false); + }); + socket.on("timeout", () => { + socket.destroy(); + resolve(false); + }); + }); +} + +function httpGetOk(url: string): Promise { + return new Promise((resolve, reject) => { + const req = http.get(url, {timeout: 3000}, (res) => { + res.resume(); // drain the response + // Accept any response (2xx, 3xx, 4xx) — the service is reachable + resolve(res.statusCode !== undefined && res.statusCode > 0); + }); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error("timeout")); + }); + }); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts new file mode 100644 index 0000000..30652f9 --- /dev/null +++ b/cli/src/modules/stack/types/config.ts @@ -0,0 +1,121 @@ +/** + * Stack module types - single source of truth for stack configuration + */ + +// ============================================ +// Per-Service Runtime Config (fully resolved with defaults) +// ============================================ + +export interface StackPostgresConfig { + image: string; + enabled: boolean; + port: number; + user: string; + password: string; + database: string; + pgVersion: number; + volume: string; +} + +export interface StackKeycloakConfig { + image: string; + enabled: boolean; + port: number; + adminUser: string; + adminPassword: string; + realm: string; + volume: string; +} + +export interface StackPostgrestConfig { + image: string; + enabled: boolean; + port: number; + dbSchema: string; + dbAnonRole: string; + jwtSecret: string; +} + +// ============================================ +// Fully Resolved Runtime Config +// ============================================ + +export interface StackConfig { + postgres: StackPostgresConfig; + keycloak: StackKeycloakConfig; + postgrest: StackPostgrestConfig; + network: string; +} + +// ============================================ +// Public Config Shape (postkit.config.json — committed) +// ============================================ + +export interface StackPostgresPublicConfig { + enabled?: boolean; + port?: number; + pgVersion?: number; + image?: string; + database?: string; + volume?: string; +} + +export interface StackKeycloakPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + realm?: string; + volume?: string; +} + +export interface StackPostgrestPublicConfig { + enabled?: boolean; + port?: number; + image?: string; + dbSchema?: string; + dbAnonRole?: string; +} + +export interface StackPublicConfig { + postgres?: StackPostgresPublicConfig; + keycloak?: StackKeycloakPublicConfig; + postgrest?: StackPostgrestPublicConfig; + network?: string; +} + +// ============================================ +// Secrets Config Shape (postkit.secrets.json — gitignored) +// ============================================ + +export interface StackPostgresSecrets { + user?: string; + password?: string; +} + +export interface StackKeycloakSecrets { + adminUser?: string; + adminPassword?: string; +} + +export interface StackPostgrestSecrets { + jwtSecret?: string; +} + +export interface StackSecretsConfig { + postgres?: StackPostgresSecrets; + keycloak?: StackKeycloakSecrets; + postgrest?: StackPostgrestSecrets; +} + +// ============================================ +// Docker Compose Status Types +// ============================================ + +export interface ServiceStatus { + name: string; + service: string; + state: string; + health: string; + ports: string; + publisherPort: number | null; +} diff --git a/cli/src/modules/stack/types/index.ts b/cli/src/modules/stack/types/index.ts new file mode 100644 index 0000000..2a26cc8 --- /dev/null +++ b/cli/src/modules/stack/types/index.ts @@ -0,0 +1,15 @@ +export type { + StackPostgresConfig, + StackKeycloakConfig, + StackPostgrestConfig, + StackConfig, + StackPostgresPublicConfig, + StackKeycloakPublicConfig, + StackPostgrestPublicConfig, + StackPublicConfig, + StackPostgresSecrets, + StackKeycloakSecrets, + StackPostgrestSecrets, + StackSecretsConfig, + ServiceStatus, +} from "./config"; diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts new file mode 100644 index 0000000..40a2d8d --- /dev/null +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -0,0 +1,258 @@ +import crypto from "crypto"; +import fs from "fs"; +import path from "path"; +import {z} from "zod"; +import {getPostkitDir, loadPostkitConfig, getSecretsFilePath} from "../../../common/config"; +import type { + StackConfig, + StackPostgresConfig, + StackKeycloakConfig, + StackPostgrestConfig, + StackSecretsConfig, +} from "../types/config"; + +// Re-export for convenience +export type {StackConfig, StackSecretsConfig} from "../types/config"; + +// ============================================ +// Constants & Defaults +// ============================================ + +const DEFAULT_POSTGRES_IMAGE = "postgres:16-alpine"; +const DEFAULT_KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:26.6"; +const DEFAULT_POSTGREST_IMAGE = "postgrest/postgrest:latest"; +const DEFAULT_NETWORK = "postkit-net"; + +const DEFAULT_POSTGRES_PORT = 25432; +const DEFAULT_KEYCLOAK_PORT = 28080; +const DEFAULT_POSTGREST_PORT = 3000; + +// ============================================ +// Zod Schemas +// ============================================ + +const PostgresPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + pgVersion: z.number().int().min(12).max(18).optional(), + image: z.string().min(1).optional(), + database: z.string().min(1).optional(), + volume: z.string().min(1).optional(), +}); + +const KeycloakPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), + realm: z.string().min(1).optional(), + volume: z.string().min(1).optional(), +}); + +const PostgrestPublicSchema = z.object({ + enabled: z.boolean().optional(), + port: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), + dbSchema: z.string().min(1).optional(), + dbAnonRole: z.string().min(1).optional(), +}); + +const StackPublicSchema = z.object({ + postgres: PostgresPublicSchema.optional(), + keycloak: KeycloakPublicSchema.optional(), + postgrest: PostgrestPublicSchema.optional(), + network: z.string().min(1).optional(), +}); + +const PostgresSecretsSchema = z.object({ + user: z.string().min(1).optional(), + password: z.string().min(1).optional(), +}); + +const KeycloakSecretsSchema = z.object({ + adminUser: z.string().min(1).optional(), + adminPassword: z.string().min(1).optional(), +}); + +const PostgrestSecretsSchema = z.object({ + jwtSecret: z.string().min(1).optional(), +}); + +const StackSecretsSchema = z.object({ + postgres: PostgresSecretsSchema.optional(), + keycloak: KeycloakSecretsSchema.optional(), + postgrest: PostgrestSecretsSchema.optional(), +}); + +// ============================================ +// Helpers +// ============================================ + +function generateSecret(length = 32): string { + return crypto.randomBytes(length).toString("hex"); +} + +function formatZodErrors(error: z.ZodError): string { + const lines = ["Invalid stack configuration:"]; + for (const issue of error.issues) { + const p = issue.path.join("."); + lines.push(` - ${p}: ${issue.message}`); + } + return lines.join("\n"); +} + +// ============================================ +// Config Loader +// ============================================ + +export function getStackConfig(): StackConfig { + const config = loadPostkitConfig(); + const raw = config.stack ?? {}; + + // Validate public config + const pubResult = StackPublicSchema.safeParse(raw); + if (!pubResult.success) { + throw new Error(formatZodErrors(pubResult.error)); + } + const pub = pubResult.data; + + // Validate secrets + const secretsRaw = (raw as Record).postgres || + (raw as Record).keycloak || + (raw as Record).postgrest + ? raw + : {}; + + const secResult = StackSecretsSchema.safeParse(secretsRaw); + if (!secResult.success) { + throw new Error(formatZodErrors(secResult.error)); + } + // Secrets are already merged into config by loadPostkitConfig() + + // Build resolved configs + const pg = raw as Record; + const pgPub = (pub.postgres ?? {}) as Record; + const kcPub = (pub.keycloak ?? {}) as Record; + const prPub = (pub.postgrest ?? {}) as Record; + + const postgres: StackPostgresConfig = { + image: (pgPub.image as string) ?? DEFAULT_POSTGRES_IMAGE, + enabled: (pgPub.enabled as boolean) ?? true, + port: (pgPub.port as number) ?? DEFAULT_POSTGRES_PORT, + user: (pg.user as string) ?? "postgres", + password: (pg.password as string) ?? "", + database: (pgPub.database as string) ?? "postkit", + pgVersion: (pgPub.pgVersion as number) ?? 16, + volume: (pgPub.volume as string) ?? "postkit-pgdata", + }; + + const keycloak: StackKeycloakConfig = { + image: (kcPub.image as string) ?? DEFAULT_KEYCLOAK_IMAGE, + enabled: (kcPub.enabled as boolean) ?? true, + port: (kcPub.port as number) ?? DEFAULT_KEYCLOAK_PORT, + adminUser: (pg.adminUser as string) ?? "admin", + adminPassword: (pg.adminPassword as string) ?? "", + realm: (kcPub.realm as string) ?? "postkit", + volume: (kcPub.volume as string) ?? "postkit-keycloak-data", + }; + + const postgrest: StackPostgrestConfig = { + image: (prPub.image as string) ?? DEFAULT_POSTGREST_IMAGE, + enabled: (prPub.enabled as boolean) ?? true, + port: (prPub.port as number) ?? DEFAULT_POSTGREST_PORT, + dbSchema: (prPub.dbSchema as string) ?? "public", + dbAnonRole: (prPub.dbAnonRole as string) ?? "anon", + jwtSecret: (pg.jwtSecret as string) ?? "", + }; + + return { + postgres, + keycloak, + postgrest, + network: pub.network ?? DEFAULT_NETWORK, + }; +} + +// ============================================ +// Secrets Auto-Generation +// ============================================ + +/** + * Ensure all required secrets have values. Generates random ones for any that + * are empty and writes them back to postkit.secrets.json. + * Returns the updated StackConfig. + */ +export function ensureStackSecrets(config: StackConfig): StackConfig { + let needsWrite = false; + const secretsPath = getSecretsFilePath(); + + // Read current secrets file + const secrets: Record = fs.existsSync(secretsPath) + ? JSON.parse(fs.readFileSync(secretsPath, "utf-8")) + : {}; + + const stackSecrets = ((secrets.stack ?? {}) as Record>); + if (!secrets.stack) { + secrets.stack = {}; + } + const ss = secrets.stack as Record>; + + // Ensure postgres secrets + if (!ss.postgres) ss.postgres = {}; + if (!config.postgres.password) { + if (!ss.postgres.password) { + ss.postgres.password = generateSecret(16); + needsWrite = true; + } + config.postgres.password = ss.postgres.password; + } + if (!ss.postgres.user) { + ss.postgres.user = config.postgres.user; + needsWrite = true; + } else { + config.postgres.user = ss.postgres.user; + } + + // Ensure keycloak secrets + if (!ss.keycloak) ss.keycloak = {}; + if (!config.keycloak.adminPassword) { + if (!ss.keycloak.adminPassword) { + ss.keycloak.adminPassword = generateSecret(16); + needsWrite = true; + } + config.keycloak.adminPassword = ss.keycloak.adminPassword; + } + if (!ss.keycloak.adminUser) { + ss.keycloak.adminUser = config.keycloak.adminUser; + needsWrite = true; + } else { + config.keycloak.adminUser = ss.keycloak.adminUser; + } + + // Ensure postgrest secrets + if (!ss.postgrest) ss.postgrest = {}; + if (!config.postgrest.jwtSecret) { + if (!ss.postgrest.jwtSecret) { + ss.postgrest.jwtSecret = generateSecret(32); + needsWrite = true; + } + config.postgrest.jwtSecret = ss.postgrest.jwtSecret; + } + + if (needsWrite) { + fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2) + "\n", "utf-8"); + } + + return config; +} + +// ============================================ +// Path Helpers +// ============================================ + +export function getStackDir(): string { + return path.join(getPostkitDir(), "stack"); +} + +export function getComposeFilePath(): string { + return path.join(getStackDir(), "docker-compose.yml"); +} From 4bd6b870a47a3061e02bf0d349c87c6511b5e2fa Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 14 May 2026 19:13:10 +0530 Subject: [PATCH 02/23] feat: implement stack module for managing local Docker-based development services --- cli/docs/stack.md | 338 ++++++++++++++++++++ cli/package-lock.json | 4 +- cli/src/modules/stack/commands/up.ts | 6 +- cli/src/modules/stack/services/compose.ts | 44 ++- cli/src/modules/stack/services/health.ts | 15 +- cli/src/modules/stack/types/config.ts | 16 + cli/src/modules/stack/utils/stack-config.ts | 21 ++ 7 files changed, 431 insertions(+), 13 deletions(-) create mode 100644 cli/docs/stack.md diff --git a/cli/docs/stack.md b/cli/docs/stack.md new file mode 100644 index 0000000..d9a5cb4 --- /dev/null +++ b/cli/docs/stack.md @@ -0,0 +1,338 @@ +# PostKit Stack Module + +The `stack` module manages a local Docker-based backend environment for development. It spins up **PostgreSQL**, **Keycloak**, and **PostgREST** as Docker containers — wired together on a shared network — using a generated `docker-compose.yml` that is written to `.postkit/stack/`. + +--- + +## Prerequisites + +- Docker Desktop installed and **running** +- Docker Compose V2 (included in Docker Desktop by default) + +--- + +## Services + +| Service | Default Image | Default Port | Purpose | +|---------|--------------|-------------|---------| +| **postgres** | `postgres:16-alpine` | `25432` | PostgreSQL database | +| **keycloak** | `quay.io/keycloak/keycloak:26.6` | `28080` | Auth / identity provider | +| **postgrest** | `postgrest/postgrest:latest` | `3000` | Auto REST API over Postgres | +| **traefik** | `traefik:v3.3` | `80` (HTTP) / `8080` (dashboard) | Reverse proxy + routing | + +**Dependency rule**: Keycloak and PostgREST both depend on Postgres and Traefik. Starting either one automatically includes both. + +### Traefik Routing + +Traefik listens on port `80` and routes incoming requests by hostname: + +| URL | Routes to | Service | +|-----|-----------|---------| +| `http://keycloak.localhost` | `:8080` | Keycloak | +| `http://api.localhost` | `:3000` | PostgREST | +| `http://localhost:8080/dashboard/` | — | Traefik dashboard | + +Postgres is TCP and accessed directly on port `25432` — not routed through Traefik. + +--- + +## Configuration + +Stack config is split across two files: + +### `postkit.config.json` (committed to git) + +Non-sensitive settings — ports, images, database name, realm: + +```json +{ + "stack": { + "postgres": { + "port": 25432, + "pgVersion": 16, + "database": "myapp", + "image": "postgres:16-alpine", + "volume": "postkit-pgdata" + }, + "keycloak": { + "port": 28080, + "realm": "myrealm", + "image": "quay.io/keycloak/keycloak:26.6", + "volume": "postkit-keycloak-data" + }, + "postgrest": { + "port": 3000, + "dbSchema": "public", + "dbAnonRole": "anon" + }, + "traefik": { + "httpPort": 80, + "dashboardPort": 8080, + "image": "traefik:v3.3" + }, + "network": "postkit-net" + } +} +``` + +All fields are optional — defaults are used for anything omitted. + +### `postkit.secrets.json` (gitignored) + +Credentials only: + +```json +{ + "stack": { + "postgres": { + "user": "myuser", + "password": "..." + }, + "keycloak": { + "adminUser": "admin", + "adminPassword": "..." + }, + "postgrest": { + "jwtSecret": "..." + } + } +} +``` + +**Auto-generation**: If passwords or the JWT secret are missing on first `stack up`, PostKit generates cryptographically random values and writes them into `postkit.secrets.json` automatically. You never need to set them manually. + +--- + +## Commands + +### `postkit stack up [services...]` + +Start all services or a specific subset. + +```bash +postkit stack up # Start all enabled services +postkit stack up postgres # Postgres only +postkit stack up postgres keycloak # Postgres + Keycloak + Traefik (auto) +postkit stack up traefik # Traefik only +postkit stack up --no-wait # Start without waiting for health checks +``` + +**What happens (step by step):** + +``` +1. Check Docker + Docker Compose V2 are available +2. Load config from postkit.config.json + postkit.secrets.json +3. Auto-generate any missing secrets → write to postkit.secrets.json +4. Resolve which services to start + └─ If keycloak or postgrest selected → add postgres automatically +5. Generate docker-compose.yml → write to .postkit/stack/docker-compose.yml +6. Run: docker compose up -d +7. Wait for health checks (unless --no-wait): + └─ postgres → TCP connection probe on port + └─ keycloak → HTTP GET http://localhost:/ + └─ postgrest → HTTP GET http://localhost:/ +8. Print service summary table with URLs and ports +``` + +**Output after success:** + +``` +✔ Stack is running! + +Service URL Port +────────────────────────────────────────────────────────────────────── +PostgreSQL postgres://myuser:***@localhost:25432/myapp 25432 +Keycloak http://localhost:28080 28080 +PostgREST http://localhost:3000 3000 +Traefik http://localhost:8080/dashboard/ 8080 + +Routing: + http://keycloak.localhost → Keycloak + http://api.localhost → PostgREST +``` + +--- + +### `postkit stack down [--volumes]` + +Stop and remove all stack containers. + +```bash +postkit stack down # Stop containers, keep data volumes +postkit stack down --volumes # Stop containers AND delete persistent data +``` + +**What happens:** + +``` +1. Check .postkit/stack/docker-compose.yml exists + └─ Error if not found (stack was never started) +2. Run: docker compose down [--volumes] +3. Containers removed; volumes preserved unless --volumes passed +``` + +> **Data safety**: Without `--volumes`, PostgreSQL data and Keycloak data survive in Docker named volumes (`postkit-pgdata`, `postkit-keycloak-data`). Re-running `stack up` picks up where you left off. Use `--volumes` only when you want a clean slate. + +--- + +### `postkit stack status` + +Show the current state of all stack containers. + +```bash +postkit stack status # Human-readable table +postkit stack status --json # Machine-readable JSON output +``` + +**Output:** + +``` +PostKit Stack Status + +Service Container State Health Ports +──────────────────────────────────────────────────────── +postgres postkit-postgres running healthy 25432:5432 +keycloak postkit-keycloak running healthy 28080:8080 +postgrest postkit-postgrest running 3000:3000 +``` + +With `--json`, returns the raw `ServiceStatus[]` array: +```json +[ + { + "name": "postkit-postgres", + "service": "postgres", + "state": "running", + "health": "healthy", + "ports": "25432:5432", + "publisherPort": 25432 + } +] +``` + +**Error:** Throws if `.postkit/stack/docker-compose.yml` does not exist — run `stack up` first. + +--- + +### `postkit stack logs [service]` + +Tail logs from one or all services. Follows output by default (like `docker logs -f`). + +```bash +postkit stack logs # Follow all services +postkit stack logs postgres # Postgres logs only +postkit stack logs keycloak --no-follow # Print last 100 lines and exit +postkit stack logs postgrest -n 50 # Last 50 lines, then follow +``` + +**Options:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-f, --follow` | `true` | Stream logs continuously | +| `--no-follow` | — | Print last N lines and exit | +| `-n, --tail ` | `100` | Number of lines to show | + +Runs until you press `Ctrl+C`. Output is piped directly to your terminal (colour, formatting preserved). + +--- + +### `postkit stack restart [service]` + +Restart one service or all services. Waits for health checks after restart. + +```bash +postkit stack restart # Restart all services +postkit stack restart keycloak # Restart Keycloak only +postkit stack restart postgres # Restart Postgres only +``` + +**What happens:** + +``` +1. Check .postkit/stack/docker-compose.yml exists +2. Run: docker compose restart [service] +3. Wait for health checks on restarted service(s) + └─ Non-fatal if still starting — warns and continues +``` + +--- + +## Full Workflow Example + +```bash +# 1. Start everything for the first time +postkit stack up +# → Secrets auto-generated and saved to postkit.secrets.json +# → All three services start, health checks pass + +# 2. Check what's running +postkit stack status + +# 3. Watch Keycloak logs while configuring a realm +postkit stack logs keycloak + +# 4. Restart PostgREST after changing db.dbAnonRole in config +postkit stack restart postgrest + +# 5. End of day — stop containers but keep DB data +postkit stack down + +# 6. Next day — pick up where you left off +postkit stack up + +# 7. Full reset — delete all data and start fresh +postkit stack down --volumes +postkit stack up +``` + +--- + +## Internal File Layout + +``` +.postkit/ +└── stack/ + └── docker-compose.yml ← generated on every `stack up`, never committed +``` + +The compose file is **regenerated every time** `stack up` runs from the current config. You should never edit it manually — changes will be overwritten. + +--- + +## How Health Checks Work + +PostKit waits up to **120 seconds** for each service (60 attempts × 2 second delay): + +| Service | Check type | What it probes | +|---------|-----------|---------------| +| postgres | TCP | Port reachable (`net.connect`) | +| keycloak | HTTP GET | `http://localhost:/` — any response | +| postgrest | HTTP GET | `http://localhost:/` — any response | +| traefik | HTTP GET | `http://localhost:/dashboard/` — any response | + +All checks run in parallel. If any service does not become healthy in time, a warning is shown but the command does not fail — the stack may still be starting. + +--- + +## Defaults Reference + +| Setting | Default | +|---------|---------| +| Postgres image | `postgres:16-alpine` | +| Postgres port | `25432` | +| Postgres database | `postkit` | +| Postgres user | `postgres` | +| Postgres volume | `postkit-pgdata` | +| Keycloak image | `quay.io/keycloak/keycloak:26.6` | +| Keycloak port | `28080` | +| Keycloak realm | `postkit` | +| Keycloak volume | `postkit-keycloak-data` | +| PostgREST image | `postgrest/postgrest:latest` | +| PostgREST port | `3000` | +| PostgREST db schema | `public` | +| PostgREST anon role | `anon` | +| Traefik image | `traefik:v3.3` | +| Traefik HTTP port | `80` | +| Traefik dashboard port | `8080` | +| Docker network | `postkit-net` | diff --git a/cli/package-lock.json b/cli/package-lock.json index 5998c46..f8527cb 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@appritech/postkit", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@appritech/postkit", - "version": "1.2.0", + "version": "1.2.1", "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 9bde06c..4603d7c 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -69,9 +69,11 @@ export async function upCommand( case "postgres": return ["PostgreSQL", `postgres://${config.postgres.user}:***@localhost:${config.postgres.port}/${config.postgres.database}`, String(config.postgres.port)]; case "keycloak": - return ["Keycloak", `http://localhost:${config.keycloak.port}`, String(config.keycloak.port)]; + return ["Keycloak", `http://keycloak.localhost`, `${config.traefik.httpPort} (Traefik)`]; case "postgrest": - return ["PostgREST", `http://localhost:${config.postgrest.port}`, String(config.postgrest.port)]; + return ["PostgREST", `http://api.localhost`, `${config.traefik.httpPort} (Traefik)`]; + case "traefik": + return ["Traefik", `http://localhost:${config.traefik.dashboardPort}/dashboard/`, String(config.traefik.dashboardPort)]; default: return [s, "", ""]; } diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index c2544de..0140528 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -4,7 +4,7 @@ import type {StackConfig} from "../types/config"; import {getStackDir} from "../utils/stack-config"; /** All supported service names. */ -export const ALL_SERVICES = ["postgres", "keycloak", "postgrest"] as const; +export const ALL_SERVICES = ["postgres", "keycloak", "postgrest", "traefik"] as const; export type ServiceName = (typeof ALL_SERVICES)[number]; /** @@ -34,9 +34,11 @@ export function getSelectedServices( }); // Always include postgres if keycloak or postgrest are selected + // Always include traefik if keycloak or postgrest are selected const set = new Set(selected as ServiceName[]); if (set.has("keycloak") || set.has("postgrest")) { set.add("postgres"); + set.add("traefik"); } return Array.from(set); @@ -51,6 +53,10 @@ export function generateComposeFile( ): string { const sections: string[] = ["services:"]; + if (services.includes("traefik")) { + sections.push(renderTraefik(config)); + } + if (services.includes("postgres")) { sections.push(renderPostgres(config)); } @@ -139,8 +145,6 @@ function renderKeycloak(config: StackConfig): string { image: ${kc.image} container_name: postkit-keycloak command: start-dev - ports: - - "${kc.port}:8080" environment: KC_DB: postgres KC_DB_URL: jdbc:postgresql://postgres:5432/${pg.database} @@ -148,6 +152,11 @@ function renderKeycloak(config: StackConfig): string { KC_DB_PASSWORD: ${pg.password} KEYCLOAK_ADMIN: ${kc.adminUser} KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} + labels: + - "traefik.enable=true" + - "traefik.http.routers.keycloak.rule=Host(\`keycloak.localhost\`)" + - "traefik.http.routers.keycloak.entrypoints=web" + - "traefik.http.services.keycloak.loadbalancer.server.port=8080" depends_on: postgres: condition: service_healthy @@ -169,13 +178,16 @@ function renderPostgrest(config: StackConfig): string { postgrest: image: ${pr.image} container_name: postkit-postgrest - ports: - - "${pr.port}:3000" environment: PGRST_DB_URI: postgres://${pg.user}:${pg.password}@postgres:5432/${pg.database} PGRST_DB_SCHEMAS: ${pr.dbSchema} PGRST_DB_ANON_ROLE: ${pr.dbAnonRole} PGRST_JWT_SECRET: ${pr.jwtSecret} + labels: + - "traefik.enable=true" + - "traefik.http.routers.postgrest.rule=Host(\`api.localhost\`)" + - "traefik.http.routers.postgrest.entrypoints=web" + - "traefik.http.services.postgrest.loadbalancer.server.port=3000" depends_on: postgres: condition: service_healthy @@ -183,3 +195,25 @@ function renderPostgrest(config: StackConfig): string { - ${config.network} `; } + +function renderTraefik(config: StackConfig): string { + const tr = config.traefik; + return ` + traefik: + image: ${tr.image} + container_name: postkit-traefik + command: + - "--api.insecure=true" + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:${tr.httpPort}" + ports: + - "${tr.httpPort}:${tr.httpPort}" + - "${tr.dashboardPort}:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + networks: + - ${config.network} +`; +} diff --git a/cli/src/modules/stack/services/health.ts b/cli/src/modules/stack/services/health.ts index 731e2b5..619a087 100644 --- a/cli/src/modules/stack/services/health.ts +++ b/cli/src/modules/stack/services/health.ts @@ -61,19 +61,26 @@ export async function waitForAllServices( break; } case "keycloak": { - const url = `http://localhost:${config.keycloak.port}/`; + const url = `http://keycloak.localhost/`; const check = waitForHttp(url, "Keycloak") .then(() => { spinner.text = `${spinner.text} (keycloak ready)`; }); checks.push(check); break; } case "postgrest": { - const url = `http://localhost:${config.postgrest.port}/`; + const url = `http://api.localhost/`; const check = waitForHttp(url, "PostgREST") .then(() => { spinner.text = `${spinner.text} (postgrest ready)`; }); checks.push(check); break; } + case "traefik": { + const url = `http://localhost:${config.traefik.dashboardPort}/dashboard/`; + const check = waitForHttp(url, "Traefik") + .then(() => { spinner.text = `${spinner.text} (traefik ready)`; }); + checks.push(check); + break; + } } } @@ -107,8 +114,8 @@ function httpGetOk(url: string): Promise { return new Promise((resolve, reject) => { const req = http.get(url, {timeout: 3000}, (res) => { res.resume(); // drain the response - // Accept any response (2xx, 3xx, 4xx) — the service is reachable - resolve(res.statusCode !== undefined && res.statusCode > 0); + // Accept 1xx–4xx; reject 5xx (e.g. Traefik 502 when backend not ready yet) + resolve(res.statusCode !== undefined && res.statusCode > 0 && res.statusCode < 500); }); req.on("error", reject); req.on("timeout", () => { diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index 30652f9..827c6b5 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -36,6 +36,13 @@ export interface StackPostgrestConfig { jwtSecret: string; } +export interface StackTraefikConfig { + image: string; + enabled: boolean; + httpPort: number; + dashboardPort: number; +} + // ============================================ // Fully Resolved Runtime Config // ============================================ @@ -44,6 +51,7 @@ export interface StackConfig { postgres: StackPostgresConfig; keycloak: StackKeycloakConfig; postgrest: StackPostgrestConfig; + traefik: StackTraefikConfig; network: string; } @@ -76,10 +84,18 @@ export interface StackPostgrestPublicConfig { dbAnonRole?: string; } +export interface StackTraefikPublicConfig { + enabled?: boolean; + httpPort?: number; + dashboardPort?: number; + image?: string; +} + export interface StackPublicConfig { postgres?: StackPostgresPublicConfig; keycloak?: StackKeycloakPublicConfig; postgrest?: StackPostgrestPublicConfig; + traefik?: StackTraefikPublicConfig; network?: string; } diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts index 40a2d8d..6e95fae 100644 --- a/cli/src/modules/stack/utils/stack-config.ts +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -8,6 +8,7 @@ import type { StackPostgresConfig, StackKeycloakConfig, StackPostgrestConfig, + StackTraefikConfig, StackSecretsConfig, } from "../types/config"; @@ -21,11 +22,14 @@ export type {StackConfig, StackSecretsConfig} from "../types/config"; const DEFAULT_POSTGRES_IMAGE = "postgres:16-alpine"; const DEFAULT_KEYCLOAK_IMAGE = "quay.io/keycloak/keycloak:26.6"; const DEFAULT_POSTGREST_IMAGE = "postgrest/postgrest:latest"; +const DEFAULT_TRAEFIK_IMAGE = "traefik:v3.3"; const DEFAULT_NETWORK = "postkit-net"; const DEFAULT_POSTGRES_PORT = 25432; const DEFAULT_KEYCLOAK_PORT = 28080; const DEFAULT_POSTGREST_PORT = 3000; +const DEFAULT_TRAEFIK_HTTP_PORT = 80; +const DEFAULT_TRAEFIK_DASHBOARD_PORT = 8080; // ============================================ // Zod Schemas @@ -56,10 +60,18 @@ const PostgrestPublicSchema = z.object({ dbAnonRole: z.string().min(1).optional(), }); +const TraefikPublicSchema = z.object({ + enabled: z.boolean().optional(), + httpPort: z.number().int().min(1).max(65535).optional(), + dashboardPort: z.number().int().min(1).max(65535).optional(), + image: z.string().min(1).optional(), +}); + const StackPublicSchema = z.object({ postgres: PostgresPublicSchema.optional(), keycloak: KeycloakPublicSchema.optional(), postgrest: PostgrestPublicSchema.optional(), + traefik: TraefikPublicSchema.optional(), network: z.string().min(1).optional(), }); @@ -133,6 +145,7 @@ export function getStackConfig(): StackConfig { const pgPub = (pub.postgres ?? {}) as Record; const kcPub = (pub.keycloak ?? {}) as Record; const prPub = (pub.postgrest ?? {}) as Record; + const trPub = (pub.traefik ?? {}) as Record; const postgres: StackPostgresConfig = { image: (pgPub.image as string) ?? DEFAULT_POSTGRES_IMAGE, @@ -164,10 +177,18 @@ export function getStackConfig(): StackConfig { jwtSecret: (pg.jwtSecret as string) ?? "", }; + const traefik: StackTraefikConfig = { + image: (trPub.image as string) ?? DEFAULT_TRAEFIK_IMAGE, + enabled: (trPub.enabled as boolean) ?? true, + httpPort: (trPub.httpPort as number) ?? DEFAULT_TRAEFIK_HTTP_PORT, + dashboardPort: (trPub.dashboardPort as number) ?? DEFAULT_TRAEFIK_DASHBOARD_PORT, + }; + return { postgres, keycloak, postgrest, + traefik, network: pub.network ?? DEFAULT_NETWORK, }; } From 661833201fb74342161a0124ce0dd79f4e90fb68 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 14 May 2026 19:49:04 +0530 Subject: [PATCH 03/23] feat: migrate postgrest jwt authentication from static secret to JWKS-based configuration --- cli/src/modules/stack/services/compose.ts | 2 +- cli/src/modules/stack/types/config.ts | 42 ++++++++++++++++--- cli/src/modules/stack/utils/stack-config.ts | 45 ++++++++++++++------- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 0140528..9e1dd50 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -182,7 +182,7 @@ function renderPostgrest(config: StackConfig): string { PGRST_DB_URI: postgres://${pg.user}:${pg.password}@postgres:5432/${pg.database} PGRST_DB_SCHEMAS: ${pr.dbSchema} PGRST_DB_ANON_ROLE: ${pr.dbAnonRole} - PGRST_JWT_SECRET: ${pr.jwtSecret} + PGRST_JWT_JWKS: '${JSON.stringify(config.jwks)}' labels: - "traefik.enable=true" - "traefik.http.routers.postgrest.rule=Host(\`api.localhost\`)" diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index 827c6b5..d9020e8 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -33,7 +33,31 @@ export interface StackPostgrestConfig { port: number; dbSchema: string; dbAnonRole: string; - jwtSecret: string; +} + +// ============================================ +// JWKS / JWK Types +// ============================================ + +export interface StackJwkKey { + kty: string; + kid?: string; + alg?: string; + use?: string; + n?: string; + e?: string; + k?: string; + key_ops?: string[]; +} + +export interface StackJwksSecrets { + keys: StackJwkKey[]; + urlSigningKey?: StackJwkKey; +} + +export interface StackClientSecrets { + secret?: string; + token?: string; } export interface StackTraefikConfig { @@ -53,6 +77,9 @@ export interface StackConfig { postgrest: StackPostgrestConfig; traefik: StackTraefikConfig; network: string; + jwks: StackJwksSecrets; + jwk?: StackJwkKey; + clients?: Record; } // ============================================ @@ -84,6 +111,11 @@ export interface StackPostgrestPublicConfig { dbAnonRole?: string; } +export interface StackKeycloakPublicConfigExtended extends StackKeycloakPublicConfig { + clientRealm?: string; + clients?: string[]; +} + export interface StackTraefikPublicConfig { enabled?: boolean; httpPort?: number; @@ -113,14 +145,12 @@ export interface StackKeycloakSecrets { adminPassword?: string; } -export interface StackPostgrestSecrets { - jwtSecret?: string; -} - export interface StackSecretsConfig { postgres?: StackPostgresSecrets; keycloak?: StackKeycloakSecrets; - postgrest?: StackPostgrestSecrets; + jwks?: StackJwksSecrets; + jwk?: StackJwkKey; + clients?: Record; } // ============================================ diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts index 6e95fae..e1c07f1 100644 --- a/cli/src/modules/stack/utils/stack-config.ts +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -10,6 +10,9 @@ import type { StackPostgrestConfig, StackTraefikConfig, StackSecretsConfig, + StackJwksSecrets, + StackJwkKey, + StackClientSecrets, } from "../types/config"; // Re-export for convenience @@ -85,14 +88,9 @@ const KeycloakSecretsSchema = z.object({ adminPassword: z.string().min(1).optional(), }); -const PostgrestSecretsSchema = z.object({ - jwtSecret: z.string().min(1).optional(), -}); - const StackSecretsSchema = z.object({ postgres: PostgresSecretsSchema.optional(), keycloak: KeycloakSecretsSchema.optional(), - postgrest: PostgrestSecretsSchema.optional(), }); // ============================================ @@ -103,6 +101,11 @@ function generateSecret(length = 32): string { return crypto.randomBytes(length).toString("hex"); } +function generateOctJwk(kid = "postkit-signing-key"): StackJwkKey { + const k = crypto.randomBytes(32).toString("base64url"); + return {kty: "oct", kid, alg: "HS256", k}; +} + function formatZodErrors(error: z.ZodError): string { const lines = ["Invalid stack configuration:"]; for (const issue of error.issues) { @@ -174,7 +177,6 @@ export function getStackConfig(): StackConfig { port: (prPub.port as number) ?? DEFAULT_POSTGREST_PORT, dbSchema: (prPub.dbSchema as string) ?? "public", dbAnonRole: (prPub.dbAnonRole as string) ?? "anon", - jwtSecret: (pg.jwtSecret as string) ?? "", }; const traefik: StackTraefikConfig = { @@ -184,12 +186,26 @@ export function getStackConfig(): StackConfig { dashboardPort: (trPub.dashboardPort as number) ?? DEFAULT_TRAEFIK_DASHBOARD_PORT, }; + // Read jwks / jwk / clients from secrets (populated by ensureStackSecrets / stack keys) + const secretsFile: Record = fs.existsSync(getSecretsFilePath()) + ? JSON.parse(fs.readFileSync(getSecretsFilePath(), "utf-8")) + : {}; + const ss = ((secretsFile.stack ?? {}) as Record); + + const jwks: StackJwksSecrets = (ss.jwks as StackJwksSecrets) ?? {keys: []}; + const jwk: StackJwkKey | undefined = ss.jwk as StackJwkKey | undefined; + const clients: Record | undefined = + ss.clients as Record | undefined; + return { postgres, keycloak, postgrest, traefik, network: pub.network ?? DEFAULT_NETWORK, + jwks, + jwk, + clients, }; } @@ -249,14 +265,15 @@ export function ensureStackSecrets(config: StackConfig): StackConfig { config.keycloak.adminUser = ss.keycloak.adminUser; } - // Ensure postgrest secrets - if (!ss.postgrest) ss.postgrest = {}; - if (!config.postgrest.jwtSecret) { - if (!ss.postgrest.jwtSecret) { - ss.postgrest.jwtSecret = generateSecret(32); - needsWrite = true; - } - config.postgrest.jwtSecret = ss.postgrest.jwtSecret; + // Ensure jwks — auto-generate initial oct key if absent + const jwksEntry = ss.jwks as {keys?: StackJwkKey[]; urlSigningKey?: StackJwkKey} | undefined; + if (!jwksEntry || !jwksEntry.keys || jwksEntry.keys.length === 0) { + const octKey = generateOctJwk("storage-url-signing-key"); + ss.jwks = {keys: [octKey], urlSigningKey: octKey} as unknown as Record; + config.jwks = {keys: [octKey], urlSigningKey: octKey}; + needsWrite = true; + } else { + config.jwks = jwksEntry as StackJwksSecrets; } if (needsWrite) { From dda4252b34be1150fbb78bb221f96c6e84f8381a Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 14 May 2026 22:46:17 +0530 Subject: [PATCH 04/23] feat: implement stack keys command to fetch and sync Keycloak JWKs and client credentials with automatic PostgREST updates. --- cli/package-lock.json | 4 +- cli/src/modules/auth/commands/export.ts | 4 +- cli/src/modules/auth/commands/import.ts | 4 +- cli/src/modules/auth/utils/auth-config.ts | 109 +++++-- cli/src/modules/stack/commands/keys.ts | 72 +++++ cli/src/modules/stack/commands/up.ts | 23 +- cli/src/modules/stack/index.ts | 15 + .../modules/stack/services/keycloak-keys.ts | 300 ++++++++++++++++++ cli/src/modules/stack/types/config.ts | 9 +- cli/src/modules/stack/utils/stack-config.ts | 9 +- 10 files changed, 513 insertions(+), 36 deletions(-) create mode 100644 cli/src/modules/stack/commands/keys.ts create mode 100644 cli/src/modules/stack/services/keycloak-keys.ts diff --git a/cli/package-lock.json b/cli/package-lock.json index f8527cb..47d0dcd 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@appritech/postkit", - "version": "1.2.1", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@appritech/postkit", - "version": "1.2.1", + "version": "1.3.0", "license": "Apache-2.0", "dependencies": { "chalk": "^5.3.0", diff --git a/cli/src/modules/auth/commands/export.ts b/cli/src/modules/auth/commands/export.ts index d35c1f3..ed336ba 100644 --- a/cli/src/modules/auth/commands/export.ts +++ b/cli/src/modules/auth/commands/export.ts @@ -1,7 +1,7 @@ import ora from "ora"; import {logger} from "../../../common/logger"; import {promptConfirm} from "../../../common/prompt"; -import {getAuthConfig} from "../utils/auth-config"; +import {getExportConfig} from "../utils/auth-config"; import { getAdminToken, exportRealm, @@ -18,7 +18,7 @@ export async function exportCommand(options: CommandOptions): Promise { // Step 1: Load config logger.step(1, 4, "Loading configuration..."); - const config = getAuthConfig(); + const config = getExportConfig(); logger.info(`Source : ${config.sourceUrl}`); logger.info(`Realm : ${config.sourceRealm}`); diff --git a/cli/src/modules/auth/commands/import.ts b/cli/src/modules/auth/commands/import.ts index 1e9f8af..f1b4992 100644 --- a/cli/src/modules/auth/commands/import.ts +++ b/cli/src/modules/auth/commands/import.ts @@ -1,7 +1,7 @@ import ora from "ora"; import {logger} from "../../../common/logger"; import {promptConfirm} from "../../../common/prompt"; -import {getAuthConfig} from "../utils/auth-config"; +import {getImportConfig} from "../utils/auth-config"; import {importRealm} from "../services/importer"; import type {CommandOptions} from "../../../common/types"; @@ -13,7 +13,7 @@ export async function importCommand(options: CommandOptions): Promise { // Step 1: Load config logger.step(1, 3, "Loading configuration..."); - const config = getAuthConfig(); + const config = getImportConfig(); logger.info(`Target : ${config.targetUrl}`); logger.info(`Config : ${config.cleanFilePath}`); diff --git a/cli/src/modules/auth/utils/auth-config.ts b/cli/src/modules/auth/utils/auth-config.ts index 8670c0e..b94f17a 100644 --- a/cli/src/modules/auth/utils/auth-config.ts +++ b/cli/src/modules/auth/utils/auth-config.ts @@ -7,7 +7,7 @@ import type {AuthConfig} from "../types/config"; export type {AuthConfig, AuthInputConfig} from "../types/config"; // ============================================ -// Zod Schemas for Validation +// Zod Schemas // ============================================ const AuthSourceSchema = z.object({ @@ -23,7 +23,27 @@ const AuthTargetSchema = z.object({ adminPass: z.string().min(1, "Target admin password is required"), }); -const AuthConfigInputSchema = z.object({ +// export: source (full) only +const ExportConfigSchema = z.object({ + source: AuthSourceSchema, + configCliImage: z.string().optional(), +}); + +// import: source.realm to locate the file + target (empty strings treated as not configured) +const ImportConfigSchema = z.object({ + source: z.object({ + realm: z.string().min(1, "source.realm is required to locate the realm file"), + }), + target: z.object({ + url: z.string(), + adminUser: z.string(), + adminPass: z.string(), + }).optional(), + configCliImage: z.string().optional(), +}); + +// sync: full source + full target +const SyncConfigSchema = z.object({ source: AuthSourceSchema, target: AuthTargetSchema, configCliImage: z.string().optional(), @@ -33,45 +53,88 @@ const AuthConfigInputSchema = z.object({ // Error Formatting // ============================================ -/** - * Format Zod validation errors into user-friendly messages - */ function formatZodErrors(error: z.ZodError): string { const lines = ["Invalid auth configuration:"]; for (const issue of error.issues) { - const path = issue.path.join("."); - lines.push(` • ${path}: ${issue.message}`); + const p = issue.path.join("."); + lines.push(` • ${p}: ${issue.message}`); } return lines.join("\n"); } // ============================================ -// Config Loader +// Config Loaders // ============================================ -/** - * Get validated auth configuration - * @throws Error if configuration is invalid - */ -export function getAuthConfig(): AuthConfig { +const DEFAULT_CONFIG_CLI_IMAGE = "adorsys/keycloak-config-cli:latest-26"; + +/** Used by `auth export` — only source is required. */ +export function getExportConfig(): AuthConfig { const config = loadPostkitConfig(); + const result = ExportConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); - // Validate with Zod - const result = AuthConfigInputSchema.safeParse(config.auth); + const auth = result.data; + const authDir = getPostkitAuthDir(); + const outputFilename = `${auth.source.realm}.json`; - if (!result.success) { - throw new Error(formatZodErrors(result.error)); - } + return { + sourceUrl: auth.source.url, + sourceAdminUser: auth.source.adminUser, + sourceAdminPass: auth.source.adminPass, + sourceRealm: auth.source.realm, + targetUrl: "", + targetAdminUser: "", + targetAdminPass: "", + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, + rawFilePath: path.join(authDir, "raw", outputFilename), + cleanFilePath: path.join(authDir, "realm", outputFilename), + }; +} + +/** Used by `auth import` — source.realm + target required (must be explicitly configured). */ +export function getImportConfig(): AuthConfig { + const config = loadPostkitConfig(); + const result = ImportConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); const auth = result.data; + const t = auth.target; + + if (!t?.url?.trim() || !t?.adminUser?.trim() || !t?.adminPass?.trim()) { + throw new Error( + "Target Keycloak not configured.\n" + + "Add auth.target.url, auth.target.adminUser, and auth.target.adminPass to postkit.secrets.json.", + ); + } + + const authDir = getPostkitAuthDir(); + const outputFilename = `${auth.source.realm}.json`; + + return { + sourceUrl: "", + sourceAdminUser: "", + sourceAdminPass: "", + sourceRealm: auth.source.realm, + targetUrl: t.url, + targetAdminUser: t.adminUser, + targetAdminPass: t.adminPass, + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, + rawFilePath: path.join(authDir, "raw", outputFilename), + cleanFilePath: path.join(authDir, "realm", outputFilename), + }; +} - // Use .postkit/auth/ as default locations with realm name as filename +/** Used by `auth sync` — both source and target required. */ +export function getAuthConfig(): AuthConfig { + const config = loadPostkitConfig(); + const result = SyncConfigSchema.safeParse(config.auth); + if (!result.success) throw new Error(formatZodErrors(result.error)); + + const auth = result.data; const authDir = getPostkitAuthDir(); const outputFilename = `${auth.source.realm}.json`; - const configCliImage = - auth.configCliImage || "adorsys/keycloak-config-cli:6.4.0-24"; - // Return flattened structure for easier use in commands return { sourceUrl: auth.source.url, sourceAdminUser: auth.source.adminUser, @@ -80,7 +143,7 @@ export function getAuthConfig(): AuthConfig { targetUrl: auth.target.url, targetAdminUser: auth.target.adminUser, targetAdminPass: auth.target.adminPass, - configCliImage, + configCliImage: auth.configCliImage ?? DEFAULT_CONFIG_CLI_IMAGE, rawFilePath: path.join(authDir, "raw", outputFilename), cleanFilePath: path.join(authDir, "realm", outputFilename), }; diff --git a/cli/src/modules/stack/commands/keys.ts b/cli/src/modules/stack/commands/keys.ts new file mode 100644 index 0000000..bc65603 --- /dev/null +++ b/cli/src/modules/stack/commands/keys.ts @@ -0,0 +1,72 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig} from "../utils/stack-config"; +import {getComposeFilePath} from "../utils/stack-config"; +import {fetchAndMergeKeys, writeKeysToSecrets} from "../services/keycloak-keys"; +import {composeRestart} from "../services/docker-compose"; +import {waitForAllServices} from "../services/health"; + +export interface KeysOptions extends CommandOptions { + restart?: boolean; + clients?: string; +} + +export async function keysCommand(options: KeysOptions): Promise { + logger.heading("PostKit Stack Keys"); + + // Check stack compose file exists (stack must have been started at least once) + const composePath = getComposeFilePath(); + if (!fs.existsSync(composePath)) { + logger.error("Stack is not running. Run 'postkit stack up' first."); + return; + } + + const config = getStackConfig(); + + // Override clients from CLI flag if provided + if (options.clients) { + config.keycloakClients = options.clients + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + const spinner = ora("Connecting to Keycloak...").start(); + try { + const result = await fetchAndMergeKeys(config, spinner); + spinner.succeed("Keys fetched from Keycloak"); + + writeKeysToSecrets(result); + logger.success("Secrets updated in postkit.secrets.json"); + + // Summary + logger.blank(); + logger.info(`RSA key fetched: ${result.jwk.kid ?? "unknown"}`); + logger.info(`Total JWKS keys: ${result.jwks.keys.length}`); + if (Object.keys(result.clients).length > 0) { + logger.info("Client credentials:"); + for (const [name] of Object.entries(result.clients)) { + logger.info(` ${name}: secret + token fetched`); + } + } + + if (options.restart) { + const restartSpinner = ora("Restarting PostgREST with updated JWKS...").start(); + // Re-read config with updated jwks and regenerate the compose file + const updatedConfig = getStackConfig(); + const {writeComposeFile, ALL_SERVICES} = await import("../services/compose"); + writeComposeFile(updatedConfig, [...ALL_SERVICES]); + await composeRestart(composePath, "postgrest"); + await waitForAllServices(updatedConfig, ["postgrest"], restartSpinner); + restartSpinner.succeed("PostgREST restarted with updated JWKS"); + } else { + logger.blank(); + logger.info("Run 'postkit stack restart postgrest' to apply JWKS to PostgREST."); + } + } catch (error) { + spinner.fail("Failed to fetch keys from Keycloak"); + logger.error(String((error as Error).message)); + } +} diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 4603d7c..b3ee917 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -8,6 +8,7 @@ import {waitForAllServices} from "../services/health"; export interface UpOptions extends CommandOptions { wait?: boolean; + keysRun?: boolean; } export async function upCommand( @@ -58,7 +59,27 @@ export async function upCommand( } } - // Step 7: Print summary + // Step 7: Auto-fetch keys from Keycloak (unless --no-keys) + if (options.keysRun !== false && selected.includes("keycloak")) { + const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); + try { + const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); + const result = await fetchAndMergeKeys(config, keysSpinner); + writeKeysToSecrets(result); + // Regenerate compose with new jwks and recreate postgrest + if (selected.includes("postgrest")) { + const updatedConfig = getStackConfig(); + const newComposeFile = writeComposeFile(updatedConfig, selected); + await composeUp(newComposeFile, ["postgrest"]); + } + keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); + } catch (error) { + keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); + logger.warn("Run 'postkit stack keys' after Keycloak is configured."); + } + } + + // Step 8: Print summary logger.blank(); logger.success("Stack is running!"); logger.blank(); diff --git a/cli/src/modules/stack/index.ts b/cli/src/modules/stack/index.ts index 4ca2f9d..5d64e43 100644 --- a/cli/src/modules/stack/index.ts +++ b/cli/src/modules/stack/index.ts @@ -5,6 +5,7 @@ import {downCommand} from "./commands/down"; import {statusCommand} from "./commands/status"; import {logsCommand} from "./commands/logs"; import {restartCommand} from "./commands/restart"; +import {keysCommand} from "./commands/keys"; export function registerStackModule(program: Command): void { const stack = program @@ -17,6 +18,7 @@ export function registerStackModule(program: Command): void { .description("Start all or selected backend services") .argument("[services...]", "Services to start (postgres, keycloak, postgrest)") .option("--no-wait", "Skip health check waiting") + .option("--no-keys", "Skip auto-fetching Keycloak JWKs after startup") .action(async (services: string[], cmdOptions: Record) => { await withInitCheck(async () => { const options = {...program.opts(), ...cmdOptions}; @@ -73,4 +75,17 @@ export function registerStackModule(program: Command): void { await restartCommand(options as never, service); }); }); + + // Keys command + stack + .command("keys") + .description("Fetch JWKs and client credentials from Keycloak into secrets") + .option("--restart", "Restart PostgREST after updating secrets") + .option("--clients ", "Comma-separated client names to fetch (overrides config)") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await keysCommand(options as never); + }); + }); } diff --git a/cli/src/modules/stack/services/keycloak-keys.ts b/cli/src/modules/stack/services/keycloak-keys.ts new file mode 100644 index 0000000..9b7d9d5 --- /dev/null +++ b/cli/src/modules/stack/services/keycloak-keys.ts @@ -0,0 +1,300 @@ +import http from "http"; +import fs from "fs"; +import type {Ora} from "ora"; +import {getSecretsFilePath} from "../../../common/config"; +import type {StackConfig, StackJwkKey, StackJwksSecrets, StackClientSecrets} from "../types/config"; + +// ============================================ +// Public Result Types +// ============================================ + +export interface KeysResult { + jwks: StackJwksSecrets; + jwk: StackJwkKey; + clients: Record; +} + +// ============================================ +// URL Helpers +// ============================================ + +/** + * Returns the Keycloak URL via Traefik. + * Uses http://keycloak.localhost if httpPort is 80, otherwise includes the port. + */ +export function getKeycloakUrl(config: StackConfig): string { + if (config.traefik.httpPort === 80) { + return "http://keycloak.localhost"; + } + return `http://keycloak.localhost:${config.traefik.httpPort}`; +} + +// ============================================ +// HTTP Helpers (Node built-in only) +// ============================================ + +/** + * Perform an HTTP GET request. Returns the response body string. + * Throws on non-2xx status codes. + */ +function httpGet(url: string, bearerToken?: string): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (bearerToken) { + headers["Authorization"] = `Bearer ${bearerToken}`; + } + + const req = http.get(url, {headers, timeout: 15000}, (res) => { + let body = ""; + res.on("data", (chunk: Buffer) => { body += chunk.toString(); }); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolve(body); + } else { + reject(new Error(`GET ${url} returned ${status}: ${body.slice(0, 200)}`)); + } + }); + }); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`GET ${url} timed out`)); + }); + }); +} + +/** + * Perform an HTTP POST request. Returns the response body string. + * Throws on non-2xx status codes. + */ +function httpPost( + url: string, + body: string, + contentType = "application/x-www-form-urlencoded", +): Promise { + return new Promise((resolve, reject) => { + const urlObj = new URL(url); + const options: http.RequestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 80, + path: urlObj.pathname + urlObj.search, + method: "POST", + headers: { + "Content-Type": contentType, + "Content-Length": Buffer.byteLength(body), + }, + timeout: 15000, + }; + + const req = http.request(options, (res) => { + let responseBody = ""; + res.on("data", (chunk: Buffer) => { responseBody += chunk.toString(); }); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { + resolve(responseBody); + } else { + reject(new Error(`POST ${url} returned ${status}: ${responseBody.slice(0, 200)}`)); + } + }); + }); + + req.on("error", reject); + req.on("timeout", () => { + req.destroy(); + reject(new Error(`POST ${url} timed out`)); + }); + + req.write(body); + req.end(); + }); +} + +// ============================================ +// Keycloak API Calls +// ============================================ + +/** + * Fetch the JWKS from Keycloak's OIDC endpoint for the given realm. + */ +export async function fetchKeycloakJwks( + keycloakUrl: string, + realm: string, +): Promise { + const url = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/certs`; + const body = await httpGet(url); + const parsed = JSON.parse(body) as {keys: StackJwkKey[]}; + return parsed.keys ?? []; +} + +/** + * Extract the primary RSA signing key from a JWKS key list. + * Returns only the fields needed for JWT verification. + */ +export function extractRsaKey(keys: StackJwkKey[]): StackJwkKey | undefined { + const rsaKey = keys.find( + (k) => k.kty === "RSA" && (k.use === "sig" || k.use === undefined), + ); + if (!rsaKey) return undefined; + + return { + kid: rsaKey.kid, + kty: "RSA", + alg: "RS256", + use: "sig", + key_ops: ["verify"], + n: rsaKey.n, + e: rsaKey.e, + }; +} + +/** + * Obtain a Keycloak admin access token from the master realm. + */ +export async function getAdminToken( + keycloakUrl: string, + adminUser: string, + adminPassword: string, +): Promise { + const url = `${keycloakUrl}/realms/master/protocol/openid-connect/token`; + const body = [ + `username=${encodeURIComponent(adminUser)}`, + `password=${encodeURIComponent(adminPassword)}`, + "grant_type=password", + "client_id=admin-cli", + ].join("&"); + + const responseBody = await httpPost(url, body); + const parsed = JSON.parse(responseBody) as {access_token: string}; + return parsed.access_token; +} + +/** + * Fetch credentials for a single Keycloak client. + * Returns the client secret and a client-credentials access token. + */ +export async function fetchClientCredentials( + keycloakUrl: string, + clientRealm: string, + clientName: string, + adminToken: string, +): Promise { + // Step 1: Look up the client's internal UUID + const listUrl = `${keycloakUrl}/admin/realms/${clientRealm}/clients?clientId=${encodeURIComponent(clientName)}`; + const listBody = await httpGet(listUrl, adminToken); + const clients = JSON.parse(listBody) as Array<{id: string}>; + if (!clients || clients.length === 0) { + throw new Error(`Keycloak client "${clientName}" not found in realm "${clientRealm}"`); + } + const uuid = clients[0].id; + + // Step 2: Fetch the client secret + const secretUrl = `${keycloakUrl}/admin/realms/${clientRealm}/clients/${uuid}/client-secret`; + const secretBody = await httpGet(secretUrl, adminToken); + const secretParsed = JSON.parse(secretBody) as {value: string}; + const secret = secretParsed.value; + + // Step 3: Exchange client credentials for an access token + const tokenUrl = `${keycloakUrl}/realms/${clientRealm}/protocol/openid-connect/token`; + const tokenBody = [ + `client_id=${encodeURIComponent(clientName)}`, + `client_secret=${encodeURIComponent(secret)}`, + "grant_type=client_credentials", + ].join("&"); + + const tokenResponse = await httpPost(tokenUrl, tokenBody); + const tokenParsed = JSON.parse(tokenResponse) as {access_token: string}; + const token = tokenParsed.access_token; + + return {secret, token}; +} + +// ============================================ +// Main Fetch Orchestration +// ============================================ + +/** + * Fetch JWKs and client credentials from a running Keycloak and merge them + * with the existing oct signing key. + */ +export async function fetchAndMergeKeys( + config: StackConfig, + spinner?: Ora, +): Promise { + const keycloakUrl = getKeycloakUrl(config); + + // Fetch OIDC JWKs + if (spinner) spinner.text = "Fetching JWKs from Keycloak..."; + const oidcKeys = await fetchKeycloakJwks(keycloakUrl, config.keycloak.realm); + + // Extract RSA signing key + const jwk = extractRsaKey(oidcKeys); + if (!jwk) { + throw new Error( + `No RSA signing key found in Keycloak realm "${config.keycloak.realm}". ` + + "Ensure the realm is configured and Keycloak is fully initialised.", + ); + } + + // Preserve the existing oct URL-signing key + const existingOctKey = config.jwks.urlSigningKey; + + // Build merged JWKS: RSA keys from Keycloak + existing oct key + const mergedKeys: StackJwkKey[] = [...oidcKeys]; + if (existingOctKey) { + mergedKeys.push(existingOctKey); + } + + const jwks: StackJwksSecrets = { + keys: mergedKeys, + ...(existingOctKey ? {urlSigningKey: existingOctKey} : {}), + }; + + // Fetch admin token + if (spinner) spinner.text = "Getting admin token..."; + const adminToken = await getAdminToken( + keycloakUrl, + config.keycloak.adminUser, + config.keycloak.adminPassword, + ); + + // Fetch credentials for each configured client + const clients: Record = {}; + for (const clientName of config.keycloakClients) { + if (spinner) spinner.text = `Fetching credentials for client "${clientName}"...`; + clients[clientName] = await fetchClientCredentials( + keycloakUrl, + config.keycloak.clientRealm, + clientName, + adminToken, + ); + } + + return {jwks, jwk, clients}; +} + +// ============================================ +// Secrets Writer +// ============================================ + +/** + * Write the fetched keys/clients back to postkit.secrets.json. + */ +export function writeKeysToSecrets(result: KeysResult): void { + const secretsPath = getSecretsFilePath(); + const secrets: Record = fs.existsSync(secretsPath) + ? (JSON.parse(fs.readFileSync(secretsPath, "utf-8")) as Record) + : {}; + + if (!secrets.stack) { + secrets.stack = {}; + } + const ss = secrets.stack as Record; + ss.jwks = result.jwks; + ss.jwk = result.jwk; + ss.clients = result.clients; + + fs.writeFileSync(secretsPath, JSON.stringify(secrets, null, 2) + "\n", "utf-8"); +} diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index d9020e8..5f76e67 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -24,6 +24,7 @@ export interface StackKeycloakConfig { adminUser: string; adminPassword: string; realm: string; + clientRealm: string; volume: string; } @@ -80,6 +81,7 @@ export interface StackConfig { jwks: StackJwksSecrets; jwk?: StackJwkKey; clients?: Record; + keycloakClients: string[]; } // ============================================ @@ -101,6 +103,8 @@ export interface StackKeycloakPublicConfig { image?: string; realm?: string; volume?: string; + clientRealm?: string; + clients?: string[]; } export interface StackPostgrestPublicConfig { @@ -111,11 +115,6 @@ export interface StackPostgrestPublicConfig { dbAnonRole?: string; } -export interface StackKeycloakPublicConfigExtended extends StackKeycloakPublicConfig { - clientRealm?: string; - clients?: string[]; -} - export interface StackTraefikPublicConfig { enabled?: boolean; httpPort?: number; diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts index e1c07f1..c3a16ca 100644 --- a/cli/src/modules/stack/utils/stack-config.ts +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -53,6 +53,8 @@ const KeycloakPublicSchema = z.object({ image: z.string().min(1).optional(), realm: z.string().min(1).optional(), volume: z.string().min(1).optional(), + clientRealm: z.string().min(1).optional(), + clients: z.array(z.string()).optional(), }); const PostgrestPublicSchema = z.object({ @@ -161,16 +163,20 @@ export function getStackConfig(): StackConfig { volume: (pgPub.volume as string) ?? "postkit-pgdata", }; + const kcRealm = (kcPub.realm as string) ?? "postkit"; const keycloak: StackKeycloakConfig = { image: (kcPub.image as string) ?? DEFAULT_KEYCLOAK_IMAGE, enabled: (kcPub.enabled as boolean) ?? true, port: (kcPub.port as number) ?? DEFAULT_KEYCLOAK_PORT, adminUser: (pg.adminUser as string) ?? "admin", adminPassword: (pg.adminPassword as string) ?? "", - realm: (kcPub.realm as string) ?? "postkit", + realm: kcRealm, + clientRealm: (kcPub.clientRealm as string) ?? kcRealm, volume: (kcPub.volume as string) ?? "postkit-keycloak-data", }; + const keycloakClients: string[] = (kcPub.clients as string[]) ?? []; + const postgrest: StackPostgrestConfig = { image: (prPub.image as string) ?? DEFAULT_POSTGREST_IMAGE, enabled: (prPub.enabled as boolean) ?? true, @@ -206,6 +212,7 @@ export function getStackConfig(): StackConfig { jwks, jwk, clients, + keycloakClients, }; } From d69d8bfc72689ecf4a5908fd67752b67836f6c4a Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Thu, 14 May 2026 22:47:28 +0530 Subject: [PATCH 05/23] feat: update stack configuration types and add null check for Keycloak client retrieval --- cli/src/modules/stack/services/keycloak-keys.ts | 2 +- cli/src/modules/stack/types/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/src/modules/stack/services/keycloak-keys.ts b/cli/src/modules/stack/services/keycloak-keys.ts index 9b7d9d5..37c54aa 100644 --- a/cli/src/modules/stack/services/keycloak-keys.ts +++ b/cli/src/modules/stack/services/keycloak-keys.ts @@ -185,7 +185,7 @@ export async function fetchClientCredentials( const listUrl = `${keycloakUrl}/admin/realms/${clientRealm}/clients?clientId=${encodeURIComponent(clientName)}`; const listBody = await httpGet(listUrl, adminToken); const clients = JSON.parse(listBody) as Array<{id: string}>; - if (!clients || clients.length === 0) { + if (!clients || clients.length === 0 || !clients[0]) { throw new Error(`Keycloak client "${clientName}" not found in realm "${clientRealm}"`); } const uuid = clients[0].id; diff --git a/cli/src/modules/stack/types/index.ts b/cli/src/modules/stack/types/index.ts index 2a26cc8..0e011a4 100644 --- a/cli/src/modules/stack/types/index.ts +++ b/cli/src/modules/stack/types/index.ts @@ -9,7 +9,9 @@ export type { StackPublicConfig, StackPostgresSecrets, StackKeycloakSecrets, - StackPostgrestSecrets, + StackJwkKey, + StackJwksSecrets, + StackClientSecrets, StackSecretsConfig, ServiceStatus, } from "./config"; From 182d6d41c179b2c236657b8210108e53321cd998 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 08:06:55 +0530 Subject: [PATCH 06/23] feat: add support for importing custom Keycloak realm templates during stack initialization --- cli/src/modules/stack/commands/realm.ts | 35 ++++ cli/src/modules/stack/commands/up.ts | 17 +- cli/src/modules/stack/index.ts | 12 ++ cli/src/modules/stack/services/realm-init.ts | 197 +++++++++++++++++++ cli/src/modules/stack/types/config.ts | 2 + cli/src/modules/stack/utils/stack-config.ts | 2 + 6 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 cli/src/modules/stack/commands/realm.ts create mode 100644 cli/src/modules/stack/services/realm-init.ts diff --git a/cli/src/modules/stack/commands/realm.ts b/cli/src/modules/stack/commands/realm.ts new file mode 100644 index 0000000..a30859c --- /dev/null +++ b/cli/src/modules/stack/commands/realm.ts @@ -0,0 +1,35 @@ +import fs from "fs"; +import ora from "ora"; +import {logger} from "../../../common/logger"; +import type {CommandOptions} from "../../../common/types"; +import {getStackConfig, getComposeFilePath} from "../utils/stack-config"; +import {importRealmTemplate} from "../services/realm-init"; + +export async function realmCommand(options: CommandOptions): Promise { + logger.heading("PostKit Stack Realm Init"); + + if (!fs.existsSync(getComposeFilePath())) { + logger.error("Stack is not running. Run 'postkit stack up' first."); + return; + } + + const config = getStackConfig(); + + if (!config.keycloak.realmTemplate) { + logger.error( + "No realm template configured. Add stack.keycloak.realmTemplate to postkit.config.json.", + ); + return; + } + + const spinner = ora("Importing realm template into Keycloak...").start(); + try { + await importRealmTemplate(config, spinner); + spinner.succeed(`Realm "${config.keycloak.realm}" imported successfully`); + logger.blank(); + logger.success("Realm initialised!"); + } catch (error) { + spinner.fail("Realm import failed"); + logger.error(String((error as Error).message)); + } +} diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index b3ee917..9b5e12c 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -79,7 +79,22 @@ export async function upCommand( } } - // Step 8: Print summary + // Step 8: Import realm template (if configured) + if (selected.includes("keycloak") && config.keycloak.realmTemplate) { + const realmSpinner = ora("Importing realm template into Keycloak...").start(); + try { + const {importRealmTemplate} = await import("../services/realm-init"); + // Re-read config to get updated jwks after keys step + const updatedConfig = getStackConfig(); + await importRealmTemplate(updatedConfig, realmSpinner); + realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); + } catch (error) { + realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); + logger.warn("Run 'postkit stack realm' to retry."); + } + } + + // Step 9: Print summary logger.blank(); logger.success("Stack is running!"); logger.blank(); diff --git a/cli/src/modules/stack/index.ts b/cli/src/modules/stack/index.ts index 5d64e43..04c7421 100644 --- a/cli/src/modules/stack/index.ts +++ b/cli/src/modules/stack/index.ts @@ -6,6 +6,7 @@ import {statusCommand} from "./commands/status"; import {logsCommand} from "./commands/logs"; import {restartCommand} from "./commands/restart"; import {keysCommand} from "./commands/keys"; +import {realmCommand} from "./commands/realm"; export function registerStackModule(program: Command): void { const stack = program @@ -88,4 +89,15 @@ export function registerStackModule(program: Command): void { await keysCommand(options as never); }); }); + + // Realm command + stack + .command("realm") + .description("Import base realm template into local Keycloak") + .action(async (cmdOptions: Record) => { + await withInitCheck(async () => { + const options = {...program.opts(), ...cmdOptions}; + await realmCommand(options as never); + }); + }); } diff --git a/cli/src/modules/stack/services/realm-init.ts b/cli/src/modules/stack/services/realm-init.ts new file mode 100644 index 0000000..dd00d16 --- /dev/null +++ b/cli/src/modules/stack/services/realm-init.ts @@ -0,0 +1,197 @@ +import fs from "fs"; +import path from "path"; +import {mkdtemp, writeFile, rm} from "fs/promises"; +import {tmpdir} from "os"; +import type {Ora} from "ora"; +import {projectRoot} from "../../../common/config"; +import {runSpawnCommand} from "../../../common/shell"; +import type {StackConfig} from "../types/config"; +import {getKeycloakUrl} from "./keycloak-keys"; + +// ============================================ +// Built-in Keycloak clients — never import these +// ============================================ + +const BUILTIN_CLIENTS = new Set([ + "account", + "account-console", + "admin-cli", + "broker", + "realm-management", + "security-admin-console", +]); + +// ============================================ +// Types +// ============================================ + +interface RealmRole { + id?: string; + name?: string; + description?: string; + composite?: boolean; + clientRole?: boolean; + attributes?: Record; + [key: string]: unknown; +} + +interface RealmClient { + id?: string; + clientId?: string; + secret?: string; + registrationAccessToken?: string; + attributes?: Record; + serviceAccountRealmRoles?: string[]; + [key: string]: unknown; +} + +// ============================================ +// Realm Template Cleaner +// ============================================ + +export function cleanRealmTemplate( + raw: Record, + realmName: string, +): Record { + // Deep clone to avoid mutating the original + const cleaned = JSON.parse(JSON.stringify(raw)) as Record; + + // Set realm name and remove id + cleaned.realm = realmName; + delete cleaned.id; + + // Filter and clean clients + if (Array.isArray(cleaned.clients)) { + const filteredClients = (cleaned.clients as RealmClient[]) + .filter((client) => { + const clientId = client.clientId as string | undefined; + return clientId !== undefined && !BUILTIN_CLIENTS.has(clientId); + }) + .map((client) => { + // Strip sensitive/generated fields + delete client.id; + delete client.secret; + delete client.registrationAccessToken; + if (client.attributes) { + delete client.attributes["client.secret.creation.time"]; + } + + // Set service account realm roles for known clients + const clientId = client.clientId as string | undefined; + if (clientId === "supabase_service") { + client.serviceAccountRealmRoles = ["service_role", "app_user"]; + } else if (clientId === "anon") { + client.serviceAccountRealmRoles = ["anon"]; + } + + return client; + }); + + cleaned.clients = filteredClients; + } + + // Ensure admin role exists in realm roles and strip ids + const roles = (cleaned.roles ?? {}) as Record; + cleaned.roles = roles; + + if (!Array.isArray(roles.realm)) { + roles.realm = []; + } + + const realmRoles = roles.realm as RealmRole[]; + + const hasAdminRole = realmRoles.some((r) => r.name === "admin"); + if (!hasAdminRole) { + realmRoles.push({ + name: "admin", + description: "Administrator role", + composite: false, + clientRole: false, + attributes: {}, + }); + } + + // Strip id from every realm role + roles.realm = realmRoles.map((role) => { + const cleaned = {...role}; + delete cleaned.id; + return cleaned; + }); + + // Remove built-in client keys from roles.client + if (roles.client && typeof roles.client === "object" && !Array.isArray(roles.client)) { + const clientRoles = roles.client as Record; + for (const builtinKey of BUILTIN_CLIENTS) { + delete clientRoles[builtinKey]; + } + } + + return cleaned; +} + +// ============================================ +// Main Import Function +// ============================================ + +export async function importRealmTemplate( + config: StackConfig, + spinner?: Ora, +): Promise { + if (!config.keycloak.realmTemplate) { + return; + } + + const templatePath = path.resolve(projectRoot, config.keycloak.realmTemplate); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Realm template not found: ${templatePath}`); + } + + const raw = JSON.parse(fs.readFileSync(templatePath, "utf-8")) as Record; + const cleaned = cleanRealmTemplate(raw, config.keycloak.realm); + + const tmpDir = await mkdtemp(path.join(tmpdir(), "postkit-realm-")); + + try { + // Write cleaned realm JSON to temp file + const cleanedRealmFile = path.join(tmpDir, "realm.json"); + await writeFile(cleanedRealmFile, JSON.stringify(cleaned, null, 2), {mode: 0o600}); + + // Build Keycloak URL + const keycloakUrl = getKeycloakUrl(config); + + // Write env file + const envFile = path.join(tmpDir, "realm-import.env"); + const envContent = [ + `KEYCLOAK_URL=${keycloakUrl}/`, + `KEYCLOAK_USER=${config.keycloak.adminUser}`, + `KEYCLOAK_PASSWORD=${config.keycloak.adminPassword}`, + "KEYCLOAK_AVAILABILITYCHECK_ENABLED=true", + "KEYCLOAK_AVAILABILITYCHECK_TIMEOUT=120s", + "IMPORT_FILES_LOCATIONS=/config/realm.json", + ].join("\n"); + + await writeFile(envFile, envContent, {mode: 0o600}); + + if (spinner) { + spinner.text = `Importing realm "${config.keycloak.realm}" into Keycloak...`; + } + + const result = await runSpawnCommand([ + "docker", "run", "--rm", + "--network", "host", + "--platform=linux/amd64", + "--env-file", envFile, + "-v", `${cleanedRealmFile}:/config/realm.json`, + "adorsys/keycloak-config-cli:latest-26", + ]); + + if (result.exitCode !== 0) { + throw new Error( + `keycloak-config-cli import failed:\n${result.stderr || result.stdout}`, + ); + } + } finally { + await rm(tmpDir, {recursive: true, force: true}); + } +} diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index 5f76e67..c8f7bf4 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -26,6 +26,7 @@ export interface StackKeycloakConfig { realm: string; clientRealm: string; volume: string; + realmTemplate: string; } export interface StackPostgrestConfig { @@ -105,6 +106,7 @@ export interface StackKeycloakPublicConfig { volume?: string; clientRealm?: string; clients?: string[]; + realmTemplate?: string; } export interface StackPostgrestPublicConfig { diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts index c3a16ca..1560ba8 100644 --- a/cli/src/modules/stack/utils/stack-config.ts +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -55,6 +55,7 @@ const KeycloakPublicSchema = z.object({ volume: z.string().min(1).optional(), clientRealm: z.string().min(1).optional(), clients: z.array(z.string()).optional(), + realmTemplate: z.string().optional(), }); const PostgrestPublicSchema = z.object({ @@ -173,6 +174,7 @@ export function getStackConfig(): StackConfig { realm: kcRealm, clientRealm: (kcPub.clientRealm as string) ?? kcRealm, volume: (kcPub.volume as string) ?? "postkit-keycloak-data", + realmTemplate: (kcPub.realmTemplate as string) ?? "", }; const keycloakClients: string[] = (kcPub.clients as string[]) ?? []; From ec621aa178a0706ba0e7a5fae340add567a208d7 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 13:13:37 +0530 Subject: [PATCH 07/23] feat: reorder realm template import to occur before JWKs key fetching during stack setup --- cli/src/modules/stack/commands/up.ts | 30 +++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 9b5e12c..5827044 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -59,7 +59,20 @@ export async function upCommand( } } - // Step 7: Auto-fetch keys from Keycloak (unless --no-keys) + // Step 7: Import realm template (if configured) — must run before JWKs fetch + if (selected.includes("keycloak") && config.keycloak.realmTemplate) { + const realmSpinner = ora("Importing realm template into Keycloak...").start(); + try { + const {importRealmTemplate} = await import("../services/realm-init"); + await importRealmTemplate(config, realmSpinner); + realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); + } catch (error) { + realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); + logger.warn("Run 'postkit stack realm' to retry."); + } + } + + // Step 8: Auto-fetch keys from Keycloak (unless --no-keys) — realm must exist first if (options.keysRun !== false && selected.includes("keycloak")) { const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); try { @@ -79,21 +92,6 @@ export async function upCommand( } } - // Step 8: Import realm template (if configured) - if (selected.includes("keycloak") && config.keycloak.realmTemplate) { - const realmSpinner = ora("Importing realm template into Keycloak...").start(); - try { - const {importRealmTemplate} = await import("../services/realm-init"); - // Re-read config to get updated jwks after keys step - const updatedConfig = getStackConfig(); - await importRealmTemplate(updatedConfig, realmSpinner); - realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); - } catch (error) { - realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); - logger.warn("Run 'postkit stack realm' to retry."); - } - } - // Step 9: Print summary logger.blank(); logger.success("Stack is running!"); From ce98cb91c99cb6614e406fd71a9058a2e0cf1160 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 13:39:17 +0530 Subject: [PATCH 08/23] feat: automate database infrastructure initialization and migrations during stack startup --- cli/src/commands/init.ts | 48 +++++++++++++++---- cli/src/modules/stack/commands/up.ts | 57 +++++++++++++++++++---- cli/src/modules/stack/services/db-init.ts | 24 ++++++++++ 3 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 cli/src/modules/stack/services/db-init.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 4435488..ccd57e4 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -17,6 +17,24 @@ import { import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; +const ROLES_SQL = `-- PostgREST roles — created by postkit init +-- Edit freely; applied by 'postkit stack up' before services start. + +DO $\$ BEGIN CREATE ROLE anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE service_role NOLOGIN BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE app_user NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; + +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role, app_user; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon; +GRANT ALL ON ALL TABLES IN SCHEMA public TO authenticated, service_role, app_user; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authenticated, service_role, app_user; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO authenticated, service_role, app_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated, service_role, app_user; +`; + // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. const GITIGNORE_ENTRIES = [ @@ -96,9 +114,6 @@ const SCAFFOLD_SECRETS_EXAMPLE: PostkitSecrets = { adminUser: "admin", adminPassword: "changeme", }, - postgrest: { - jwtSecret: "changeme-to-a-long-random-secret", - }, }, }; @@ -129,7 +144,7 @@ export async function initCommand(options: CommandOptions): Promise { } } - const totalSteps = 6; + const totalSteps = 7; // Step 1: Create .postkit/db/ directory logger.step(1, totalSteps, "Creating .postkit/db/ directory"); @@ -199,8 +214,25 @@ export async function initCommand(options: CommandOptions): Promise { spinner.succeed(`${POSTKIT_CONFIG_FILE}, ${POSTKIT_SECRETS_FILE}, and postkit.secrets.example.json created`); } - // Step 5: Update .gitignore - logger.step(5, totalSteps, "Updating .gitignore"); + // Step 5: Scaffold db/infra/roles.sql + logger.step(5, totalSteps, "Scaffolding db/infra/roles.sql"); + if (options.dryRun) { + logger.info("Dry run: would create db/infra/roles.sql with PostgREST roles"); + } else { + const spinner = ora("Creating db/infra/roles.sql...").start(); + const infraDir = path.join(projectRoot, "db", "infra"); + const rolesFile = path.join(infraDir, "roles.sql"); + fs.mkdirSync(infraDir, {recursive: true}); + if (!fs.existsSync(rolesFile)) { + fs.writeFileSync(rolesFile, ROLES_SQL); + spinner.succeed("db/infra/roles.sql created"); + } else { + spinner.succeed("db/infra/roles.sql already exists — skipped"); + } + } + + // Step 6: Update .gitignore + logger.step(6, totalSteps, "Updating .gitignore"); const gitignorePath = path.join(projectRoot, ".gitignore"); if (options.dryRun) { logger.info("Dry run: would update .gitignore with Postkit entries"); @@ -227,8 +259,8 @@ export async function initCommand(options: CommandOptions): Promise { } } - // Step 6: Summary - logger.step(6, totalSteps, "Done"); + // Step 7: Summary + logger.step(7, totalSteps, "Done"); logger.blank(); logger.success("Postkit project initialized!"); logger.blank(); diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 5827044..af10643 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -5,6 +5,7 @@ import {getStackConfig, ensureStackSecrets, getComposeFilePath} from "../utils/s import {checkDockerComposeAvailable, composeUp} from "../services/docker-compose"; import {writeComposeFile, getSelectedServices} from "../services/compose"; import {waitForAllServices} from "../services/health"; +import {applyStackDeploy} from "../services/db-init"; export interface UpOptions extends CommandOptions { wait?: boolean; @@ -35,21 +36,59 @@ export async function upCommand( const composeFile = writeComposeFile(config, selected); composeSpinner.succeed(`Compose file written to .postkit/stack/docker-compose.yml`); - // Step 5: Start services - const upSpinner = ora(`Starting services: ${serviceList}`).start(); - const result = await composeUp(composeFile, selected); + // Step 5: Start infrastructure services first (postgres + traefik) + const infraServices = ["postgres", "traefik"].filter((s) => selected.includes(s)); + const infraList = infraServices.join(", "); + const infraSpinner = ora(`Starting infrastructure: ${infraList}`).start(); + const infraResult = await composeUp(composeFile, infraServices); - if (result.exitCode !== 0) { - upSpinner.fail("Failed to start services"); - logger.error(result.stderr); + if (infraResult.exitCode !== 0) { + infraSpinner.fail("Failed to start infrastructure services"); + logger.error(infraResult.stderr); logger.info("Run 'postkit stack logs' for details."); return; } - upSpinner.succeed(`Services started: ${serviceList}`); + infraSpinner.succeed(`Infrastructure started: ${infraList}`); - // Step 6: Health checks + // Step 6: Wait for infrastructure to be healthy + if (options.wait !== false && infraServices.length > 0) { + const healthSpinner = ora("Waiting for infrastructure to become healthy...").start(); + try { + await waitForAllServices(config, infraServices, healthSpinner); + healthSpinner.succeed("Infrastructure healthy"); + } catch (error) { + healthSpinner.warn(String((error as Error).message)); + logger.warn("Infrastructure may still be starting. Attempting DB init anyway..."); + } + } + + // Step 7: Apply DB infra + committed migrations (no dry-run) + if (infraServices.includes("postgres")) { + const dbDeploySpinner = ora("Deploying DB (infra + migrations)...").start(); + try { + await applyStackDeploy(config, dbDeploySpinner); + } catch (error) { + dbDeploySpinner.warn(`DB deploy skipped: ${(error as Error).message}`); + } + } + + // Step 8: Start all remaining selected services + const remainingServices = selected.filter((s) => !infraServices.includes(s)); + if (remainingServices.length > 0) { + const upSpinner = ora(`Starting services: ${serviceList}`).start(); + const result = await composeUp(composeFile, selected); + if (result.exitCode !== 0) { + upSpinner.fail("Failed to start services"); + logger.error(result.stderr); + logger.info("Run 'postkit stack logs' for details."); + return; + } + upSpinner.succeed(`Services started: ${serviceList}`); + } + + // Step 9: Health checks for all services if (options.wait !== false) { - const healthSpinner = ora("Waiting for services to become healthy...").start(); + const healthSpinner = ora("Waiting for all services to become healthy...").start(); try { await waitForAllServices(config, selected, healthSpinner); healthSpinner.succeed("All services healthy"); diff --git a/cli/src/modules/stack/services/db-init.ts b/cli/src/modules/stack/services/db-init.ts new file mode 100644 index 0000000..6cc9f30 --- /dev/null +++ b/cli/src/modules/stack/services/db-init.ts @@ -0,0 +1,24 @@ +import ora from "ora"; +import type {StackConfig} from "../types/config"; +import {applyInfraStep} from "../../db/services/infra-generator"; +import {runCommittedMigrate} from "../../db/services/dbmate"; + +export async function applyStackDeploy( + config: StackConfig, + spinner: ReturnType, +): Promise { + const pgUrl = + `postgres://${config.postgres.user}:${encodeURIComponent(config.postgres.password)}` + + `@localhost:${config.postgres.port}/${config.postgres.database}`; + + // Apply db/infra/*.sql (roles, schemas, extensions) + await applyInfraStep(spinner, pgUrl, "stack"); + + // Apply all committed migrations — no dry-run, no cloning + spinner.start("Running committed migrations on stack..."); + const result = await runCommittedMigrate(pgUrl); + if (!result.success) { + throw new Error(`Migration failed: ${result.output}`); + } + spinner.succeed("Committed migrations applied to stack"); +} From f710f4d1dfcb5d4fc40e9c0e2c4e82c221ba037b Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 13:51:33 +0530 Subject: [PATCH 09/23] feat: extract db scaffolding logic and update Keycloak CLI image version --- cli/src/commands/init.ts | 32 +++------------------ cli/src/modules/db/services/scaffold.ts | 37 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 28 deletions(-) create mode 100644 cli/src/modules/db/services/scaffold.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index ccd57e4..b6cf296 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -16,24 +16,7 @@ import { } from "../common/config"; import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; - -const ROLES_SQL = `-- PostgREST roles — created by postkit init --- Edit freely; applied by 'postkit stack up' before services start. - -DO $\$ BEGIN CREATE ROLE anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE service_role NOLOGIN BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE app_user NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; - -GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role, app_user; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon; -GRANT ALL ON ALL TABLES IN SCHEMA public TO authenticated, service_role, app_user; -GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authenticated, service_role, app_user; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO authenticated, service_role, app_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated, service_role, app_user; -`; +import {scaffoldDbInfra} from "../modules/db/services/scaffold"; // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. @@ -55,7 +38,7 @@ const SCAFFOLD_PUBLIC_CONFIG: PostkitPublicConfig = { infraPath: "db/infra", }, auth: { - configCliImage: "adorsys/keycloak-config-cli:6.4.0-24", + configCliImage: "adorsys/keycloak-config-cli:latest-26", }, stack: {}, }; @@ -220,15 +203,8 @@ export async function initCommand(options: CommandOptions): Promise { logger.info("Dry run: would create db/infra/roles.sql with PostgREST roles"); } else { const spinner = ora("Creating db/infra/roles.sql...").start(); - const infraDir = path.join(projectRoot, "db", "infra"); - const rolesFile = path.join(infraDir, "roles.sql"); - fs.mkdirSync(infraDir, {recursive: true}); - if (!fs.existsSync(rolesFile)) { - fs.writeFileSync(rolesFile, ROLES_SQL); - spinner.succeed("db/infra/roles.sql created"); - } else { - spinner.succeed("db/infra/roles.sql already exists — skipped"); - } + const created = scaffoldDbInfra(); + spinner.succeed(created ? "db/infra/roles.sql created" : "db/infra/roles.sql already exists — skipped"); } // Step 6: Update .gitignore diff --git a/cli/src/modules/db/services/scaffold.ts b/cli/src/modules/db/services/scaffold.ts new file mode 100644 index 0000000..b40a800 --- /dev/null +++ b/cli/src/modules/db/services/scaffold.ts @@ -0,0 +1,37 @@ +import fs from "fs"; +import path from "path"; +import {projectRoot} from "../../../common/config"; + +const ROLES_SQL = `-- PostgREST roles — created by postkit init +-- Edit freely; applied by 'postkit stack up' before services start. + +DO $\$ BEGIN CREATE ROLE anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE service_role NOLOGIN BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; +DO $\$ BEGIN CREATE ROLE app_user NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; + +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role, app_user; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon; +GRANT ALL ON ALL TABLES IN SCHEMA public TO authenticated, service_role, app_user; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authenticated, service_role, app_user; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO authenticated, service_role, app_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated, service_role, app_user; +`; + +/** + * Scaffold db/infra/ directory and a default roles.sql for PostgREST. + * Safe to call multiple times — never overwrites existing files. + * Returns true if roles.sql was created, false if it already existed. + */ +export function scaffoldDbInfra(): boolean { + const infraDir = path.join(projectRoot, "db", "infra"); + const rolesFile = path.join(infraDir, "roles.sql"); + + fs.mkdirSync(infraDir, {recursive: true}); + + if (fs.existsSync(rolesFile)) return false; + fs.writeFileSync(rolesFile, ROLES_SQL); + return true; +} From bb00563d2b817fbb0116271424552bdda8050356 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 14:12:21 +0530 Subject: [PATCH 10/23] fix: update Keycloak health check URL, clarify volume removal messaging, and handle empty migration results gracefully --- cli/src/modules/stack/commands/down.ts | 6 ++++-- cli/src/modules/stack/services/db-init.ts | 7 ++++++- cli/src/modules/stack/services/health.ts | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/cli/src/modules/stack/commands/down.ts b/cli/src/modules/stack/commands/down.ts index 1f7ebeb..70eccc5 100644 --- a/cli/src/modules/stack/commands/down.ts +++ b/cli/src/modules/stack/commands/down.ts @@ -36,8 +36,10 @@ export async function downCommand(options: DownOptions): Promise { ); logger.blank(); - logger.info("Containers removed. Data preserved in Docker volumes."); - if (!options.volumes) { + if (options.volumes) { + logger.info("Containers and volumes removed. All data has been deleted."); + } else { + logger.info("Containers removed. Data preserved in Docker volumes."); logger.info("Use --volumes to remove persistent data as well."); } } diff --git a/cli/src/modules/stack/services/db-init.ts b/cli/src/modules/stack/services/db-init.ts index 6cc9f30..cd398d5 100644 --- a/cli/src/modules/stack/services/db-init.ts +++ b/cli/src/modules/stack/services/db-init.ts @@ -18,7 +18,12 @@ export async function applyStackDeploy( spinner.start("Running committed migrations on stack..."); const result = await runCommittedMigrate(pgUrl); if (!result.success) { - throw new Error(`Migration failed: ${result.output}`); + const out = result.output ?? ""; + if (out.toLowerCase().includes("no migration files found") || out.toLowerCase().includes("no migrations")) { + spinner.succeed("No committed migrations to apply"); + return; + } + throw new Error(`Migration failed: ${out}`); } spinner.succeed("Committed migrations applied to stack"); } diff --git a/cli/src/modules/stack/services/health.ts b/cli/src/modules/stack/services/health.ts index 619a087..8bf8d4c 100644 --- a/cli/src/modules/stack/services/health.ts +++ b/cli/src/modules/stack/services/health.ts @@ -61,7 +61,7 @@ export async function waitForAllServices( break; } case "keycloak": { - const url = `http://keycloak.localhost/`; + const url = `http://keycloak.localhost/realms/master`; const check = waitForHttp(url, "Keycloak") .then(() => { spinner.text = `${spinner.text} (keycloak ready)`; }); checks.push(check); From 1125637639908a2752aceaf93e8dc3f3ee90dc4f Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 14:56:42 +0530 Subject: [PATCH 11/23] feat: add realm template scaffolding to init command and remove redundant healthcheck from keycloak compose config --- cli/src/commands/init.ts | 27 +++++++++++++---- cli/src/modules/stack/services/compose.ts | 6 ---- cli/src/modules/stack/services/scaffold.ts | 34 ++++++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 cli/src/modules/stack/services/scaffold.ts diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index b6cf296..30613f7 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -17,6 +17,7 @@ import { import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; import {scaffoldDbInfra} from "../modules/db/services/scaffold"; +import {scaffoldRealmTemplate, DEFAULT_REALM_TEMPLATE_PATH} from "../modules/stack/services/scaffold"; // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. @@ -40,7 +41,11 @@ const SCAFFOLD_PUBLIC_CONFIG: PostkitPublicConfig = { auth: { configCliImage: "adorsys/keycloak-config-cli:latest-26", }, - stack: {}, + stack: { + keycloak: { + realmTemplate: DEFAULT_REALM_TEMPLATE_PATH, + }, + }, }; // Sensitive credentials — gitignored @@ -127,7 +132,7 @@ export async function initCommand(options: CommandOptions): Promise { } } - const totalSteps = 7; + const totalSteps = 8; // Step 1: Create .postkit/db/ directory logger.step(1, totalSteps, "Creating .postkit/db/ directory"); @@ -207,8 +212,18 @@ export async function initCommand(options: CommandOptions): Promise { spinner.succeed(created ? "db/infra/roles.sql created" : "db/infra/roles.sql already exists — skipped"); } - // Step 6: Update .gitignore - logger.step(6, totalSteps, "Updating .gitignore"); + // Step 6: Scaffold realm template + logger.step(6, totalSteps, "Scaffolding realm template"); + if (options.dryRun) { + logger.info(`Dry run: would create ${DEFAULT_REALM_TEMPLATE_PATH}`); + } else { + const spinner = ora(`Creating ${DEFAULT_REALM_TEMPLATE_PATH}...`).start(); + const created = scaffoldRealmTemplate(); + spinner.succeed(created ? `${DEFAULT_REALM_TEMPLATE_PATH} created` : `${DEFAULT_REALM_TEMPLATE_PATH} already exists — skipped`); + } + + // Step 7: Update .gitignore + logger.step(7, totalSteps, "Updating .gitignore"); const gitignorePath = path.join(projectRoot, ".gitignore"); if (options.dryRun) { logger.info("Dry run: would update .gitignore with Postkit entries"); @@ -235,8 +250,8 @@ export async function initCommand(options: CommandOptions): Promise { } } - // Step 7: Summary - logger.step(7, totalSteps, "Done"); + // Step 8: Summary + logger.step(8, totalSteps, "Done"); logger.blank(); logger.success("Postkit project initialized!"); logger.blank(); diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 9e1dd50..2eacbdf 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -160,12 +160,6 @@ function renderKeycloak(config: StackConfig): string { depends_on: postgres: condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "curl -sf http://localhost:8080/ -o /dev/null || exit 1"] - interval: 10s - timeout: 5s - retries: 15 - start_period: 45s networks: - ${config.network} `; diff --git a/cli/src/modules/stack/services/scaffold.ts b/cli/src/modules/stack/services/scaffold.ts new file mode 100644 index 0000000..e741998 --- /dev/null +++ b/cli/src/modules/stack/services/scaffold.ts @@ -0,0 +1,34 @@ +import fs from "fs"; +import path from "path"; +import {projectRoot} from "../../../common/config"; + +const DEFAULT_REALM_NAME = "postkit"; +const DEFAULT_REALM_TEMPLATE_PATH = ".postkit/auth/realm/postkit.json"; + +const MINIMAL_REALM_TEMPLATE = { + realm: DEFAULT_REALM_NAME, + enabled: true, + clients: [], + roles: { + realm: [], + client: {}, + }, +}; + +/** + * Scaffold the default realm template at .postkit/auth/realm/postkit.json. + * Safe to call multiple times — never overwrites existing files. + * Returns true if created, false if already existed. + */ +export function scaffoldRealmTemplate(): boolean { + const realmDir = path.join(projectRoot, ".postkit", "auth", "realm"); + const realmFile = path.join(realmDir, "postkit.json"); + + fs.mkdirSync(realmDir, {recursive: true}); + + if (fs.existsSync(realmFile)) return false; + fs.writeFileSync(realmFile, JSON.stringify(MINIMAL_REALM_TEMPLATE, null, 2) + "\n"); + return true; +} + +export {DEFAULT_REALM_TEMPLATE_PATH}; From d7bc7bd45ab81fe88091f43b0f9fa04fea0b56fb Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 15:10:01 +0530 Subject: [PATCH 12/23] fix: update keycloak-config-cli networking to use internal docker bridge and explicit network naming --- cli/src/modules/stack/services/compose.ts | 4 +++- cli/src/modules/stack/services/realm-init.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 2eacbdf..684771c 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -69,10 +69,12 @@ export function generateComposeFile( sections.push(renderPostgrest(config)); } - // Network + // Network — explicit name prevents docker-compose project prefix, + // so external containers (keycloak-config-cli) can join by this exact name. sections.push(` networks: ${config.network}: + name: ${config.network} driver: bridge `); diff --git a/cli/src/modules/stack/services/realm-init.ts b/cli/src/modules/stack/services/realm-init.ts index dd00d16..5d1059e 100644 --- a/cli/src/modules/stack/services/realm-init.ts +++ b/cli/src/modules/stack/services/realm-init.ts @@ -6,7 +6,7 @@ import type {Ora} from "ora"; import {projectRoot} from "../../../common/config"; import {runSpawnCommand} from "../../../common/shell"; import type {StackConfig} from "../types/config"; -import {getKeycloakUrl} from "./keycloak-keys"; +const CONFIG_CLI_IMAGE = "adorsys/keycloak-config-cli:latest-26"; // ============================================ // Built-in Keycloak clients — never import these @@ -157,13 +157,14 @@ export async function importRealmTemplate( const cleanedRealmFile = path.join(tmpDir, "realm.json"); await writeFile(cleanedRealmFile, JSON.stringify(cleaned, null, 2), {mode: 0o600}); - // Build Keycloak URL - const keycloakUrl = getKeycloakUrl(config); + // Use internal Docker DNS name — keycloak-config-cli runs inside Docker, + // so it must reach Keycloak via the container network, not the Traefik hostname. + const internalKeycloakUrl = `http://keycloak:8080`; // Write env file const envFile = path.join(tmpDir, "realm-import.env"); const envContent = [ - `KEYCLOAK_URL=${keycloakUrl}/`, + `KEYCLOAK_URL=${internalKeycloakUrl}/`, `KEYCLOAK_USER=${config.keycloak.adminUser}`, `KEYCLOAK_PASSWORD=${config.keycloak.adminPassword}`, "KEYCLOAK_AVAILABILITYCHECK_ENABLED=true", @@ -179,11 +180,10 @@ export async function importRealmTemplate( const result = await runSpawnCommand([ "docker", "run", "--rm", - "--network", "host", - "--platform=linux/amd64", + "--network", config.network, "--env-file", envFile, "-v", `${cleanedRealmFile}:/config/realm.json`, - "adorsys/keycloak-config-cli:latest-26", + CONFIG_CLI_IMAGE, ]); if (result.exitCode !== 0) { From a11315cef3229f710ade8d623b5f14ef7ec01ff3 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Fri, 15 May 2026 19:08:55 +0530 Subject: [PATCH 13/23] refactor: decouple postgres and keycloak secret resolution from raw config object --- cli/src/modules/stack/utils/stack-config.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/src/modules/stack/utils/stack-config.ts b/cli/src/modules/stack/utils/stack-config.ts index 1560ba8..3c26d64 100644 --- a/cli/src/modules/stack/utils/stack-config.ts +++ b/cli/src/modules/stack/utils/stack-config.ts @@ -147,7 +147,8 @@ export function getStackConfig(): StackConfig { // Secrets are already merged into config by loadPostkitConfig() // Build resolved configs - const pg = raw as Record; + const pgSec = ((raw as Record).postgres ?? {}) as Record; + const kcSec = ((raw as Record).keycloak ?? {}) as Record; const pgPub = (pub.postgres ?? {}) as Record; const kcPub = (pub.keycloak ?? {}) as Record; const prPub = (pub.postgrest ?? {}) as Record; @@ -157,8 +158,8 @@ export function getStackConfig(): StackConfig { image: (pgPub.image as string) ?? DEFAULT_POSTGRES_IMAGE, enabled: (pgPub.enabled as boolean) ?? true, port: (pgPub.port as number) ?? DEFAULT_POSTGRES_PORT, - user: (pg.user as string) ?? "postgres", - password: (pg.password as string) ?? "", + user: (pgSec.user as string) ?? "postgres", + password: (pgSec.password as string) ?? "", database: (pgPub.database as string) ?? "postkit", pgVersion: (pgPub.pgVersion as number) ?? 16, volume: (pgPub.volume as string) ?? "postkit-pgdata", @@ -169,8 +170,8 @@ export function getStackConfig(): StackConfig { image: (kcPub.image as string) ?? DEFAULT_KEYCLOAK_IMAGE, enabled: (kcPub.enabled as boolean) ?? true, port: (kcPub.port as number) ?? DEFAULT_KEYCLOAK_PORT, - adminUser: (pg.adminUser as string) ?? "admin", - adminPassword: (pg.adminPassword as string) ?? "", + adminUser: (kcSec.adminUser as string) ?? "admin", + adminPassword: (kcSec.adminPassword as string) ?? "", realm: kcRealm, clientRealm: (kcPub.clientRealm as string) ?? kcRealm, volume: (kcPub.volume as string) ?? "postkit-keycloak-data", From a0c54642c75f524c70662ef7f434a03cbe0f882e Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 14:52:43 +0530 Subject: [PATCH 14/23] feat: add stack state tracking to ensure realm import and key setup only run during initial deployment --- cli/src/modules/stack/commands/down.ts | 7 +++ cli/src/modules/stack/commands/up.ts | 67 +++++++++++++--------- cli/src/modules/stack/types/config.ts | 8 +++ cli/src/modules/stack/utils/stack-state.ts | 24 ++++++++ 4 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 cli/src/modules/stack/utils/stack-state.ts diff --git a/cli/src/modules/stack/commands/down.ts b/cli/src/modules/stack/commands/down.ts index 70eccc5..7f085e3 100644 --- a/cli/src/modules/stack/commands/down.ts +++ b/cli/src/modules/stack/commands/down.ts @@ -5,6 +5,7 @@ import type {CommandOptions} from "../../../common/types"; import {getComposeFilePath} from "../utils/stack-config"; import {composeDown} from "../services/docker-compose"; import {PostkitError} from "../../../common/errors"; +import {writeStackState} from "../utils/stack-state"; export interface DownOptions extends CommandOptions { volumes?: boolean; @@ -35,9 +36,15 @@ export async function downCommand(options: DownOptions): Promise { : "Stack stopped", ); + // Reset init state when volumes are wiped so next stack up re-initializes + if (options.volumes) { + writeStackState({isInitial: true}); + } + logger.blank(); if (options.volumes) { logger.info("Containers and volumes removed. All data has been deleted."); + logger.info("Next 'postkit stack up' will re-run realm import and key setup."); } else { logger.info("Containers removed. Data preserved in Docker volumes."); logger.info("Use --volumes to remove persistent data as well."); diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index af10643..34f8630 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -6,6 +6,7 @@ import {checkDockerComposeAvailable, composeUp} from "../services/docker-compose import {writeComposeFile, getSelectedServices} from "../services/compose"; import {waitForAllServices} from "../services/health"; import {applyStackDeploy} from "../services/db-init"; +import {readStackState, writeStackState} from "../utils/stack-state"; export interface UpOptions extends CommandOptions { wait?: boolean; @@ -98,37 +99,49 @@ export async function upCommand( } } - // Step 7: Import realm template (if configured) — must run before JWKs fetch - if (selected.includes("keycloak") && config.keycloak.realmTemplate) { - const realmSpinner = ora("Importing realm template into Keycloak...").start(); - try { - const {importRealmTemplate} = await import("../services/realm-init"); - await importRealmTemplate(config, realmSpinner); - realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); - } catch (error) { - realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); - logger.warn("Run 'postkit stack realm' to retry."); + // Step 7: Initial setup — realm import + JWKs fetch + // Only runs on first stack up (isInitial undefined or true). Skipped on normal restarts. + const stackState = readStackState(); + const shouldInitialize = stackState.isInitial !== false; + + if (selected.includes("keycloak") && shouldInitialize) { + // Step 7a: Import realm template (must run before JWKs fetch) + if (config.keycloak.realmTemplate) { + const realmSpinner = ora("Importing realm template into Keycloak...").start(); + try { + const {importRealmTemplate} = await import("../services/realm-init"); + await importRealmTemplate(config, realmSpinner); + realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); + } catch (error) { + realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); + logger.warn("Run 'postkit stack init' to retry."); + } } - } - // Step 8: Auto-fetch keys from Keycloak (unless --no-keys) — realm must exist first - if (options.keysRun !== false && selected.includes("keycloak")) { - const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); - try { - const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); - const result = await fetchAndMergeKeys(config, keysSpinner); - writeKeysToSecrets(result); - // Regenerate compose with new jwks and recreate postgrest - if (selected.includes("postgrest")) { - const updatedConfig = getStackConfig(); - const newComposeFile = writeComposeFile(updatedConfig, selected); - await composeUp(newComposeFile, ["postgrest"]); + // Step 7b: Fetch JWKs and client credentials — realm must exist first + if (options.keysRun !== false) { + const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); + try { + const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); + const result = await fetchAndMergeKeys(config, keysSpinner); + writeKeysToSecrets(result); + // Regenerate compose with new jwks and recreate postgrest + if (selected.includes("postgrest")) { + const updatedConfig = getStackConfig(); + const newComposeFile = writeComposeFile(updatedConfig, selected); + await composeUp(newComposeFile, ["postgrest"]); + } + keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); + } catch (error) { + keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); + logger.warn("Run 'postkit stack keys' after Keycloak is configured."); } - keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); - } catch (error) { - keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); - logger.warn("Run 'postkit stack keys' after Keycloak is configured."); } + + // Mark stack as initialized so subsequent stack up skips these steps + writeStackState({isInitial: false}); + } else if (selected.includes("keycloak") && !shouldInitialize) { + logger.info("Stack already initialized. Run 'postkit stack init' to re-run realm import."); } // Step 9: Print summary diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index c8f7bf4..d795094 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -154,6 +154,14 @@ export interface StackSecretsConfig { clients?: Record; } +// ============================================ +// Stack Runtime State (.postkit/stack/state.json — gitignored) +// ============================================ + +export interface StackState { + isInitial?: boolean; +} + // ============================================ // Docker Compose Status Types // ============================================ diff --git a/cli/src/modules/stack/utils/stack-state.ts b/cli/src/modules/stack/utils/stack-state.ts new file mode 100644 index 0000000..2d68ff5 --- /dev/null +++ b/cli/src/modules/stack/utils/stack-state.ts @@ -0,0 +1,24 @@ +import fs from "fs"; +import path from "path"; +import {getPostkitDir} from "../../../common/config"; +import type {StackState} from "../types/config"; + +function getStackStatePath(): string { + return path.join(getPostkitDir(), "stack", "state.json"); +} + +export function readStackState(): StackState { + const statePath = getStackStatePath(); + if (!fs.existsSync(statePath)) return {}; + try { + return JSON.parse(fs.readFileSync(statePath, "utf-8")) as StackState; + } catch { + return {}; + } +} + +export function writeStackState(state: StackState): void { + const statePath = getStackStatePath(); + fs.mkdirSync(path.dirname(statePath), {recursive: true}); + fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); +} From 9e5b9e620ba6145eead60d8eba558ef8cf6ac2fa Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 17:20:04 +0530 Subject: [PATCH 15/23] refactor: migrate stack initialization state from local JSON file to postgres table --- cli/src/common/config.ts | 3 + cli/src/modules/stack/commands/down.ts | 6 -- cli/src/modules/stack/commands/up.ts | 81 +++++++++++----------- cli/src/modules/stack/services/db-init.ts | 31 ++++++++- cli/src/modules/stack/types/config.ts | 8 --- cli/src/modules/stack/utils/stack-state.ts | 52 +++++++++----- 6 files changed, 109 insertions(+), 72 deletions(-) diff --git a/cli/src/common/config.ts b/cli/src/common/config.ts index 8908f8f..9880d93 100644 --- a/cli/src/common/config.ts +++ b/cli/src/common/config.ts @@ -96,6 +96,9 @@ export interface StackKeycloakPublicConfig { image?: string; realm?: string; volume?: string; + clientRealm?: string; + clients?: string[]; + realmTemplate?: string; } export interface StackPostgrestPublicConfig { diff --git a/cli/src/modules/stack/commands/down.ts b/cli/src/modules/stack/commands/down.ts index 7f085e3..2c4e621 100644 --- a/cli/src/modules/stack/commands/down.ts +++ b/cli/src/modules/stack/commands/down.ts @@ -5,7 +5,6 @@ import type {CommandOptions} from "../../../common/types"; import {getComposeFilePath} from "../utils/stack-config"; import {composeDown} from "../services/docker-compose"; import {PostkitError} from "../../../common/errors"; -import {writeStackState} from "../utils/stack-state"; export interface DownOptions extends CommandOptions { volumes?: boolean; @@ -36,11 +35,6 @@ export async function downCommand(options: DownOptions): Promise { : "Stack stopped", ); - // Reset init state when volumes are wiped so next stack up re-initializes - if (options.volumes) { - writeStackState({isInitial: true}); - } - logger.blank(); if (options.volumes) { logger.info("Containers and volumes removed. All data has been deleted."); diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 34f8630..09c5e03 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -4,9 +4,10 @@ import type {CommandOptions} from "../../../common/types"; import {getStackConfig, ensureStackSecrets, getComposeFilePath} from "../utils/stack-config"; import {checkDockerComposeAvailable, composeUp} from "../services/docker-compose"; import {writeComposeFile, getSelectedServices} from "../services/compose"; +import type {ServiceName} from "../services/compose"; import {waitForAllServices} from "../services/health"; import {applyStackDeploy} from "../services/db-init"; -import {readStackState, writeStackState} from "../utils/stack-state"; +import {readStackIsInitial, setStackInitialized} from "../utils/stack-state"; export interface UpOptions extends CommandOptions { wait?: boolean; @@ -38,7 +39,7 @@ export async function upCommand( composeSpinner.succeed(`Compose file written to .postkit/stack/docker-compose.yml`); // Step 5: Start infrastructure services first (postgres + traefik) - const infraServices = ["postgres", "traefik"].filter((s) => selected.includes(s)); + const infraServices = (["postgres", "traefik"] as ServiceName[]).filter((s) => selected.includes(s)); const infraList = infraServices.join(", "); const infraSpinner = ora(`Starting infrastructure: ${infraList}`).start(); const infraResult = await composeUp(composeFile, infraServices); @@ -100,48 +101,50 @@ export async function upCommand( } // Step 7: Initial setup — realm import + JWKs fetch - // Only runs on first stack up (isInitial undefined or true). Skipped on normal restarts. - const stackState = readStackState(); - const shouldInitialize = stackState.isInitial !== false; - - if (selected.includes("keycloak") && shouldInitialize) { - // Step 7a: Import realm template (must run before JWKs fetch) - if (config.keycloak.realmTemplate) { - const realmSpinner = ora("Importing realm template into Keycloak...").start(); - try { - const {importRealmTemplate} = await import("../services/realm-init"); - await importRealmTemplate(config, realmSpinner); - realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); - } catch (error) { - realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); - logger.warn("Run 'postkit stack init' to retry."); + // Only runs when is_initial != 'false' in postkit.stack_config. + // Skipped on normal restarts. Resets automatically when DB volumes are wiped. + if (selected.includes("keycloak")) { + const isInitial = await readStackIsInitial(config); + + if (isInitial) { + // Step 7a: Import realm template (must run before JWKs fetch) + if (config.keycloak.realmTemplate) { + const realmSpinner = ora("Importing realm template into Keycloak...").start(); + try { + const {importRealmTemplate} = await import("../services/realm-init"); + await importRealmTemplate(config, realmSpinner); + realmSpinner.succeed(`Realm "${config.keycloak.realm}" imported`); + } catch (error) { + realmSpinner.warn(`Realm import failed: ${(error as Error).message}`); + logger.warn("Run 'postkit stack init' to retry."); + } } - } - // Step 7b: Fetch JWKs and client credentials — realm must exist first - if (options.keysRun !== false) { - const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); - try { - const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); - const result = await fetchAndMergeKeys(config, keysSpinner); - writeKeysToSecrets(result); - // Regenerate compose with new jwks and recreate postgrest - if (selected.includes("postgrest")) { - const updatedConfig = getStackConfig(); - const newComposeFile = writeComposeFile(updatedConfig, selected); - await composeUp(newComposeFile, ["postgrest"]); + // Step 7b: Fetch JWKs and client credentials — realm must exist first + if (options.keysRun !== false) { + const keysSpinner = ora("Fetching JWKs and client credentials from Keycloak...").start(); + try { + const {fetchAndMergeKeys, writeKeysToSecrets} = await import("../services/keycloak-keys"); + const result = await fetchAndMergeKeys(config, keysSpinner); + writeKeysToSecrets(result); + // Regenerate compose with new jwks and recreate postgrest + if (selected.includes("postgrest")) { + const updatedConfig = getStackConfig(); + const newComposeFile = writeComposeFile(updatedConfig, selected); + await composeUp(newComposeFile, ["postgrest"]); + } + keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); + } catch (error) { + keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); + logger.warn("Run 'postkit stack keys' after Keycloak is configured."); } - keysSpinner.succeed("Keycloak JWKs fetched and PostgREST updated"); - } catch (error) { - keysSpinner.warn(`Could not fetch Keycloak keys: ${(error as Error).message}`); - logger.warn("Run 'postkit stack keys' after Keycloak is configured."); } - } - // Mark stack as initialized so subsequent stack up skips these steps - writeStackState({isInitial: false}); - } else if (selected.includes("keycloak") && !shouldInitialize) { - logger.info("Stack already initialized. Run 'postkit stack init' to re-run realm import."); + // Mark stack as initialized — subsequent stack up skips realm import + JWKs + await setStackInitialized(config); + } else { + logger.info("Stack already initialized. Run 'postkit stack init' to re-run realm import."); + } } // Step 9: Print summary diff --git a/cli/src/modules/stack/services/db-init.ts b/cli/src/modules/stack/services/db-init.ts index cd398d5..dde2eae 100644 --- a/cli/src/modules/stack/services/db-init.ts +++ b/cli/src/modules/stack/services/db-init.ts @@ -1,15 +1,40 @@ import ora from "ora"; +import {Client} from "pg"; import type {StackConfig} from "../types/config"; import {applyInfraStep} from "../../db/services/infra-generator"; import {runCommittedMigrate} from "../../db/services/dbmate"; +const POSTKIT_SCHEMA_SQL = ` +CREATE SCHEMA IF NOT EXISTS postkit; +CREATE TABLE IF NOT EXISTS postkit.stack_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ DEFAULT now() +); +`.trim(); + +export function buildPgUrl(config: StackConfig): string { + return ( + `postgres://${config.postgres.user}:${encodeURIComponent(config.postgres.password)}` + + `@localhost:${config.postgres.port}/${config.postgres.database}` + ); +} + export async function applyStackDeploy( config: StackConfig, spinner: ReturnType, ): Promise { - const pgUrl = - `postgres://${config.postgres.user}:${encodeURIComponent(config.postgres.password)}` + - `@localhost:${config.postgres.port}/${config.postgres.database}`; + const pgUrl = buildPgUrl(config); + + // Ensure postkit internal schema + stack_config table exist + spinner.start("Initialising postkit schema..."); + const client = new Client({connectionString: pgUrl}); + await client.connect(); + try { + await client.query(POSTKIT_SCHEMA_SQL); + } finally { + await client.end(); + } // Apply db/infra/*.sql (roles, schemas, extensions) await applyInfraStep(spinner, pgUrl, "stack"); diff --git a/cli/src/modules/stack/types/config.ts b/cli/src/modules/stack/types/config.ts index d795094..c8f7bf4 100644 --- a/cli/src/modules/stack/types/config.ts +++ b/cli/src/modules/stack/types/config.ts @@ -154,14 +154,6 @@ export interface StackSecretsConfig { clients?: Record; } -// ============================================ -// Stack Runtime State (.postkit/stack/state.json — gitignored) -// ============================================ - -export interface StackState { - isInitial?: boolean; -} - // ============================================ // Docker Compose Status Types // ============================================ diff --git a/cli/src/modules/stack/utils/stack-state.ts b/cli/src/modules/stack/utils/stack-state.ts index 2d68ff5..e669d61 100644 --- a/cli/src/modules/stack/utils/stack-state.ts +++ b/cli/src/modules/stack/utils/stack-state.ts @@ -1,24 +1,44 @@ -import fs from "fs"; -import path from "path"; -import {getPostkitDir} from "../../../common/config"; -import type {StackState} from "../types/config"; +import {Client} from "pg"; +import type {StackConfig} from "../types/config"; +import {buildPgUrl} from "../services/db-init"; -function getStackStatePath(): string { - return path.join(getPostkitDir(), "stack", "state.json"); -} +const KEY = "is_initial"; -export function readStackState(): StackState { - const statePath = getStackStatePath(); - if (!fs.existsSync(statePath)) return {}; +/** + * Read the is_initial flag from postkit.stack_config. + * Returns true (treat as initial) if the table/row doesn't exist or on any error. + */ +export async function readStackIsInitial(config: StackConfig): Promise { + const client = new Client({connectionString: buildPgUrl(config)}); try { - return JSON.parse(fs.readFileSync(statePath, "utf-8")) as StackState; + await client.connect(); + const res = await client.query<{value: string}>( + "SELECT value FROM postkit.stack_config WHERE key = $1", + [KEY], + ); + if (res.rows.length === 0) return true; + return (res.rows[0]?.value ?? "true") !== "false"; } catch { - return {}; + return true; + } finally { + await client.end().catch(() => undefined); } } -export function writeStackState(state: StackState): void { - const statePath = getStackStatePath(); - fs.mkdirSync(path.dirname(statePath), {recursive: true}); - fs.writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n", "utf-8"); +/** + * Mark the stack as initialized by setting is_initial = 'false' in the DB. + */ +export async function setStackInitialized(config: StackConfig): Promise { + const client = new Client({connectionString: buildPgUrl(config)}); + try { + await client.connect(); + await client.query( + `INSERT INTO postkit.stack_config (key, value, updated_at) + VALUES ($1, 'false', now()) + ON CONFLICT (key) DO UPDATE SET value = 'false', updated_at = now()`, + [KEY], + ); + } finally { + await client.end().catch(() => undefined); + } } From ca9523e638d9068a04fea391da85ac65c9c803a2 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 17:34:55 +0530 Subject: [PATCH 16/23] feat: implement robust postgres connection retry and integrate mandatory seed deployment during stack initialization --- cli/src/modules/stack/commands/up.ts | 10 ++--- cli/src/modules/stack/services/db-init.ts | 48 +++++++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 09c5e03..69c6df5 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -64,14 +64,10 @@ export async function upCommand( } } - // Step 7: Apply DB infra + committed migrations (no dry-run) + // Step 7: Apply DB infra + migrations + seeds — hard failure if this fails if (infraServices.includes("postgres")) { - const dbDeploySpinner = ora("Deploying DB (infra + migrations)...").start(); - try { - await applyStackDeploy(config, dbDeploySpinner); - } catch (error) { - dbDeploySpinner.warn(`DB deploy skipped: ${(error as Error).message}`); - } + const dbDeploySpinner = ora("Deploying DB (infra + migrations + seeds)...").start(); + await applyStackDeploy(config, dbDeploySpinner); } // Step 8: Start all remaining selected services diff --git a/cli/src/modules/stack/services/db-init.ts b/cli/src/modules/stack/services/db-init.ts index dde2eae..109be45 100644 --- a/cli/src/modules/stack/services/db-init.ts +++ b/cli/src/modules/stack/services/db-init.ts @@ -3,6 +3,7 @@ import {Client} from "pg"; import type {StackConfig} from "../types/config"; import {applyInfraStep} from "../../db/services/infra-generator"; import {runCommittedMigrate} from "../../db/services/dbmate"; +import {applySeedsStep} from "../../db/services/seed-generator"; const POSTKIT_SCHEMA_SQL = ` CREATE SCHEMA IF NOT EXISTS postkit; @@ -20,35 +21,58 @@ export function buildPgUrl(config: StackConfig): string { ); } +async function connectWithRetry(pgUrl: string, retries = 10, delayMs = 2000): Promise { + let last: Error | undefined; + for (let i = 0; i < retries; i++) { + const client = new Client({connectionString: pgUrl}); + try { + await client.connect(); + return client; + } catch (err) { + last = err as Error; + await client.end().catch(() => undefined); + await new Promise((r) => setTimeout(r, delayMs)); + } + } + throw last ?? new Error("Could not connect to postgres after retries"); +} + export async function applyStackDeploy( config: StackConfig, spinner: ReturnType, ): Promise { const pgUrl = buildPgUrl(config); - // Ensure postkit internal schema + stack_config table exist - spinner.start("Initialising postkit schema..."); - const client = new Client({connectionString: pgUrl}); - await client.connect(); + // Wait until postgres is truly ready — pg_isready can pass before queries work + spinner.start("Waiting for postgres to accept connections..."); + const client = await connectWithRetry(pgUrl); try { await client.query(POSTKIT_SCHEMA_SQL); + spinner.succeed("postkit schema initialised"); } finally { - await client.end(); + await client.end().catch(() => undefined); } - // Apply db/infra/*.sql (roles, schemas, extensions) + // Phase 1: Apply db/infra/*.sql (roles, schemas, extensions) await applyInfraStep(spinner, pgUrl, "stack"); - // Apply all committed migrations — no dry-run, no cloning - spinner.start("Running committed migrations on stack..."); + // Phase 2: Apply committed migrations + spinner.start("Running committed migrations..."); const result = await runCommittedMigrate(pgUrl); if (!result.success) { const out = result.output ?? ""; - if (out.toLowerCase().includes("no migration files found") || out.toLowerCase().includes("no migrations")) { + if ( + out.toLowerCase().includes("no migration files found") || + out.toLowerCase().includes("no migrations") + ) { spinner.succeed("No committed migrations to apply"); - return; + } else { + throw new Error(`Migration failed: ${out}`); } - throw new Error(`Migration failed: ${out}`); + } else { + spinner.succeed("Committed migrations applied"); } - spinner.succeed("Committed migrations applied to stack"); + + // Phase 3: Apply seeds + await applySeedsStep(spinner, pgUrl, "stack"); } From c653c44ffc77b8db804803e72ce2a4f832876ffb Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 17:47:01 +0530 Subject: [PATCH 17/23] feat: add KC_DB_SCHEMA to Keycloak config and update database infrastructure to scaffold roles and schema files --- cli/src/modules/db/services/scaffold.ts | 86 +++++++++++++++++------ cli/src/modules/stack/services/compose.ts | 1 + 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/cli/src/modules/db/services/scaffold.ts b/cli/src/modules/db/services/scaffold.ts index b40a800..29e39a4 100644 --- a/cli/src/modules/db/services/scaffold.ts +++ b/cli/src/modules/db/services/scaffold.ts @@ -2,36 +2,76 @@ import fs from "fs"; import path from "path"; import {projectRoot} from "../../../common/config"; -const ROLES_SQL = `-- PostgREST roles — created by postkit init --- Edit freely; applied by 'postkit stack up' before services start. - -DO $\$ BEGIN CREATE ROLE anon NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE authenticated NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE service_role NOLOGIN BYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; -DO $\$ BEGIN CREATE ROLE app_user NOLOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $\$; - -GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role, app_user; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO anon; -GRANT ALL ON ALL TABLES IN SCHEMA public TO authenticated, service_role, app_user; -GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authenticated, service_role, app_user; - -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO authenticated, service_role, app_user; -ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated, service_role, app_user; +const ROLES_SQL = `DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'app_user') THEN + CREATE ROLE app_user NOLOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator LOGIN NOSUPERUSER NOINHERIT NOCREATEDB NOCREATEROLE NOREPLICATION; + END IF; +END +$$; +`; + +const SCHEMAS_SQL = `CREATE SCHEMA IF NOT EXISTS public; + +CREATE SCHEMA IF NOT EXISTS auth; + +CREATE SCHEMA IF NOT EXISTS storage; `; /** - * Scaffold db/infra/ directory and a default roles.sql for PostgREST. + * Scaffold db/infra/ with 001_roles.sql and 002_schemas.sql for PostgREST. * Safe to call multiple times — never overwrites existing files. - * Returns true if roles.sql was created, false if it already existed. + * Returns true if any file was created, false if all already existed. */ export function scaffoldDbInfra(): boolean { const infraDir = path.join(projectRoot, "db", "infra"); - const rolesFile = path.join(infraDir, "roles.sql"); - fs.mkdirSync(infraDir, {recursive: true}); - if (fs.existsSync(rolesFile)) return false; - fs.writeFileSync(rolesFile, ROLES_SQL); - return true; + let created = false; + + const rolesFile = path.join(infraDir, "001_roles.sql"); + if (!fs.existsSync(rolesFile)) { + fs.writeFileSync(rolesFile, ROLES_SQL); + created = true; + } + + const schemasFile = path.join(infraDir, "002_schemas.sql"); + if (!fs.existsSync(schemasFile)) { + fs.writeFileSync(schemasFile, SCHEMAS_SQL); + created = true; + } + + return created; } diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 684771c..73e93af 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -152,6 +152,7 @@ function renderKeycloak(config: StackConfig): string { KC_DB_URL: jdbc:postgresql://postgres:5432/${pg.database} KC_DB_USERNAME: ${pg.user} KC_DB_PASSWORD: ${pg.password} + KC_DB_SCHEMA: auth KEYCLOAK_ADMIN: ${kc.adminUser} KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} labels: From 94d394d0bbb4a3e9ca39b543e80e2b8856181538 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 17:54:58 +0530 Subject: [PATCH 18/23] refactor: update service startup logic to correctly filter and log remaining services --- cli/src/modules/stack/commands/up.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 69c6df5..7936443 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -70,18 +70,19 @@ export async function upCommand( await applyStackDeploy(config, dbDeploySpinner); } - // Step 8: Start all remaining selected services + // Step 8: Start keycloak + postgrest — only after DB migrations are applied const remainingServices = selected.filter((s) => !infraServices.includes(s)); if (remainingServices.length > 0) { - const upSpinner = ora(`Starting services: ${serviceList}`).start(); - const result = await composeUp(composeFile, selected); + const remainingList = remainingServices.join(", "); + const upSpinner = ora(`Starting services: ${remainingList}`).start(); + const result = await composeUp(composeFile, remainingServices); if (result.exitCode !== 0) { - upSpinner.fail("Failed to start services"); + upSpinner.fail(`Failed to start services: ${remainingList}`); logger.error(result.stderr); logger.info("Run 'postkit stack logs' for details."); return; } - upSpinner.succeed(`Services started: ${serviceList}`); + upSpinner.succeed(`Services started: ${remainingList}`); } // Step 9: Health checks for all services From a823c517623f6191c7d43e314e072b4c073b8603 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 18:00:05 +0530 Subject: [PATCH 19/23] feat: update restart command to support multiple simultaneous service targets with validation --- cli/src/modules/stack/commands/keys.ts | 2 +- cli/src/modules/stack/commands/restart.ts | 40 +++++++++++++++---- cli/src/modules/stack/index.ts | 8 ++-- .../modules/stack/services/docker-compose.ts | 8 ++-- 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/cli/src/modules/stack/commands/keys.ts b/cli/src/modules/stack/commands/keys.ts index bc65603..d8f7d1b 100644 --- a/cli/src/modules/stack/commands/keys.ts +++ b/cli/src/modules/stack/commands/keys.ts @@ -58,7 +58,7 @@ export async function keysCommand(options: KeysOptions): Promise { const updatedConfig = getStackConfig(); const {writeComposeFile, ALL_SERVICES} = await import("../services/compose"); writeComposeFile(updatedConfig, [...ALL_SERVICES]); - await composeRestart(composePath, "postgrest"); + await composeRestart(composePath, ["postgrest"]); await waitForAllServices(updatedConfig, ["postgrest"], restartSpinner); restartSpinner.succeed("PostgREST restarted with updated JWKS"); } else { diff --git a/cli/src/modules/stack/commands/restart.ts b/cli/src/modules/stack/commands/restart.ts index 17d19e2..d4e0ddd 100644 --- a/cli/src/modules/stack/commands/restart.ts +++ b/cli/src/modules/stack/commands/restart.ts @@ -6,10 +6,12 @@ import {getComposeFilePath, getStackConfig} from "../utils/stack-config"; import {composeRestart} from "../services/docker-compose"; import {waitForAllServices} from "../services/health"; import {PostkitError} from "../../../common/errors"; +import {ALL_SERVICES} from "../services/compose"; +import type {ServiceName} from "../services/compose"; export async function restartCommand( options: CommandOptions, - service?: string, + services: string[] = [], ): Promise { const composeFile = getComposeFilePath(); if (!fs.existsSync(composeFile)) { @@ -19,10 +21,30 @@ export async function restartCommand( ); } - const label = service ?? "all services"; - const spinner = ora(`Restarting ${label}...`).start(); + // Validate service names + const valid = new Set(ALL_SERVICES); + const unknown = services.filter((s) => !valid.has(s)); + if (unknown.length > 0) { + throw new PostkitError( + `Unknown service(s): ${unknown.join(", ")}`, + `Available services: ${ALL_SERVICES.join(", ")}`, + ); + } + + const targets = services.length > 0 + ? (services as ServiceName[]) + : [...ALL_SERVICES]; + + const label = targets.join(", "); - const result = await composeRestart(composeFile, service); + if (options.dryRun) { + logger.info(`Dry run: would restart ${label}`); + return; + } + + const spinner = ora(`Restarting: ${label}...`).start(); + + const result = await composeRestart(composeFile, services.length > 0 ? services : undefined); if (result.exitCode !== 0) { spinner.fail(`Failed to restart ${label}`); @@ -30,13 +52,15 @@ export async function restartCommand( return; } + spinner.succeed(`Restarted: ${label}`); + // Health check the restarted services const config = getStackConfig(); - const services = service ? [service] : ["postgres", "keycloak", "postgrest"]; + const healthSpinner = ora("Waiting for services to become healthy...").start(); try { - await waitForAllServices(config, services, spinner); - spinner.succeed(`${label} restarted and healthy`); + await waitForAllServices(config, targets, healthSpinner); + healthSpinner.succeed(`${label} healthy`); } catch { - spinner.warn(`${label} restarted but may still be starting`); + healthSpinner.warn(`${label} restarted but may still be starting`); } } diff --git a/cli/src/modules/stack/index.ts b/cli/src/modules/stack/index.ts index 04c7421..7a739f6 100644 --- a/cli/src/modules/stack/index.ts +++ b/cli/src/modules/stack/index.ts @@ -68,12 +68,12 @@ export function registerStackModule(program: Command): void { // Restart command stack .command("restart") - .description("Restart a stack service") - .argument("[service]", "Service name to restart (omit for all)") - .action(async (service: string | undefined, cmdOptions: Record) => { + .description("Restart all or selected stack services") + .argument("[services...]", "Services to restart (omit for all): postgres, keycloak, postgrest, traefik") + .action(async (services: string[], cmdOptions: Record) => { await withInitCheck(async () => { const options = {...program.opts(), ...cmdOptions}; - await restartCommand(options as never, service); + await restartCommand(options as never, services); }); }); diff --git a/cli/src/modules/stack/services/docker-compose.ts b/cli/src/modules/stack/services/docker-compose.ts index 03595bc..4208d4b 100644 --- a/cli/src/modules/stack/services/docker-compose.ts +++ b/cli/src/modules/stack/services/docker-compose.ts @@ -107,15 +107,15 @@ export async function composeLogs( } /** - * Restart a specific service or all services. + * Restart specific services or all services. */ export async function composeRestart( composeFile: string, - service?: string, + services?: string[], ): Promise { const args = ["compose", "-f", composeFile, "restart"]; - if (service) { - args.push(service); + if (services && services.length > 0) { + args.push(...services); } return runDockerCompose(args); } From 5d4f664ca8e3ebfa2a00846936a4436c62c52d12 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 18 May 2026 19:36:25 +0530 Subject: [PATCH 20/23] feat: implement Keycloak provider synchronization and mount providers directory into container --- cli/src/commands/init.ts | 2 +- cli/src/modules/stack/commands/up.ts | 12 +++++ cli/src/modules/stack/services/compose.ts | 4 ++ .../modules/stack/services/sync-providers.ts | 49 ++++++++++++++++++ .../providers/primary-role-mapper-1.0.0.jar | Bin 0 -> 2327 bytes 5 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 cli/src/modules/stack/services/sync-providers.ts create mode 100644 cli/vendor/providers/primary-role-mapper-1.0.0.jar diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 30613f7..84639ea 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -163,7 +163,7 @@ export async function initCommand(options: CommandOptions): Promise { } else { const spinner = ora("Creating .postkit/auth/ directory...").start(); const postkitAuthDir = getPostkitAuthDir(); - for (const subdir of ["raw", "realm"]) { + for (const subdir of ["raw", "realm", "providers"]) { const subPath = path.join(postkitAuthDir, subdir); if (!fs.existsSync(subPath)) { fs.mkdirSync(subPath, {recursive: true}); diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 7936443..259f94a 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -8,6 +8,7 @@ import type {ServiceName} from "../services/compose"; import {waitForAllServices} from "../services/health"; import {applyStackDeploy} from "../services/db-init"; import {readStackIsInitial, setStackInitialized} from "../utils/stack-state"; +import {syncKeycloakProviders} from "../services/sync-providers"; export interface UpOptions extends CommandOptions { wait?: boolean; @@ -70,6 +71,17 @@ export async function upCommand( await applyStackDeploy(config, dbDeploySpinner); } + // Step 8a: Sync Keycloak providers (auth/providers/*/target/*.jar → .postkit/auth/providers/) + if (selected.includes("keycloak")) { + const providerSpinner = ora("Syncing Keycloak providers...").start(); + syncKeycloakProviders(providerSpinner); + if (!providerSpinner.isSpinning) { + // already succeeded/informed above + } else { + providerSpinner.succeed("Keycloak providers dir ready"); + } + } + // Step 8: Start keycloak + postgrest — only after DB migrations are applied const remainingServices = selected.filter((s) => !infraServices.includes(s)); if (remainingServices.length > 0) { diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 73e93af..152a55a 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -2,6 +2,7 @@ import fs from "fs"; import path from "path"; import type {StackConfig} from "../types/config"; import {getStackDir} from "../utils/stack-config"; +import {getProvidersDir} from "./sync-providers"; /** All supported service names. */ export const ALL_SERVICES = ["postgres", "keycloak", "postgrest", "traefik"] as const; @@ -142,6 +143,7 @@ function renderPostgres(config: StackConfig): string { function renderKeycloak(config: StackConfig): string { const kc = config.keycloak; const pg = config.postgres; + const providersDir = getProvidersDir(); return ` keycloak: image: ${kc.image} @@ -155,6 +157,8 @@ function renderKeycloak(config: StackConfig): string { KC_DB_SCHEMA: auth KEYCLOAK_ADMIN: ${kc.adminUser} KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} + volumes: + - ${providersDir}:/opt/keycloak/providers labels: - "traefik.enable=true" - "traefik.http.routers.keycloak.rule=Host(\`keycloak.localhost\`)" diff --git a/cli/src/modules/stack/services/sync-providers.ts b/cli/src/modules/stack/services/sync-providers.ts new file mode 100644 index 0000000..3fe529b --- /dev/null +++ b/cli/src/modules/stack/services/sync-providers.ts @@ -0,0 +1,49 @@ +import fs from "fs"; +import path from "path"; +import type {Ora} from "ora"; +import {cliRoot, projectRoot, getPostkitAuthDir} from "../../../common/config"; + +export function getProvidersDir(): string { + return path.join(getPostkitAuthDir(), "providers"); +} + +/** + * Copy Keycloak provider JARs into .postkit/auth/providers/ from two sources: + * 1. cli/vendor/providers/ — bundled JARs shipped with PostKit + * 2. auth/providers//target/ — project-specific JARs built locally + * The dest dir is mounted into the Keycloak container at /opt/keycloak/providers. + */ +export function syncKeycloakProviders(spinner?: Ora): void { + const destDir = getProvidersDir(); + fs.mkdirSync(destDir, {recursive: true}); + + const copied: string[] = []; + + // Source 1: bundled vendor JARs + const vendorProvidersDir = path.join(cliRoot, "vendor", "providers"); + if (fs.existsSync(vendorProvidersDir)) { + for (const file of fs.readdirSync(vendorProvidersDir)) { + if (!file.endsWith(".jar")) continue; + fs.copyFileSync(path.join(vendorProvidersDir, file), path.join(destDir, file)); + copied.push(file); + } + } + + // Source 2: project-specific JARs from auth/providers//target/ + const projectProvidersDir = path.join(projectRoot, "auth", "providers"); + if (fs.existsSync(projectProvidersDir)) { + for (const providerDir of fs.readdirSync(projectProvidersDir)) { + const targetDir = path.join(projectProvidersDir, providerDir, "target"); + if (!fs.existsSync(targetDir)) continue; + for (const file of fs.readdirSync(targetDir)) { + if (!file.endsWith(".jar")) continue; + fs.copyFileSync(path.join(targetDir, file), path.join(destDir, file)); + copied.push(file); + } + } + } + + if (copied.length > 0 && spinner) { + spinner.succeed(`Keycloak providers synced: ${copied.join(", ")}`); + } +} diff --git a/cli/vendor/providers/primary-role-mapper-1.0.0.jar b/cli/vendor/providers/primary-role-mapper-1.0.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..9ce159843ec4fe7a2d0d1dadbea8724733af8fef GIT binary patch literal 2327 zcmWIWW@h1HVBlb2IGegDh5-q10@=Q0f!1by(; z@je^;p@=7lpu!FHHmc~?^oS&qdtC(1x1;;iA9yVMfo|Yy19u31*t_y z!FCcDx;*PwCJHk#F#KX>U;sH8S%+R$acZz*-faVcJ+DQh=c=eryS>O#Q!ikL-@_{# zc`a>491}JOrEZ+&(YR!;MCUDCMqlF+f!|6Dy}7CUJk=DpG6tay#p zd|SNy$*NgaUmSwD)r=qXZ@qM_VM9wQ@0sPB_%lzsh(7#z;4_=;i}`XAXVb$c=f4%+ zvpHF6($A7TQ7V%wjduR!bl&kf@NZY@)CvDAcT8XVaMrgSZ(L4=uM@wzWYWJ{p615M zy#Dq79aqZ9yuJSM>U8zAH}eSK=GXHLsoLmmbrq zJM=s%w2}SEj~k-*HRNL$7MX58Dd-Wb@Z^?GY`=m^z~=oWsZ&3GDrR0CA^RkHW>n*5 zp%()Gb`(2^mUT~F)H$PtMWg>5f38`~hV#auw<0*>bX>!HTQ@qsFxX z-khR^y{(sd!pw#EYRtOa#;Kb(A6s+c zV!8S!j}txi=RPlJ-Xr{7agQ|L!v7Cv1bH8Oz1lZo|AY$X+s)-Ke)-O-Xl0dA`ILLL z?R4M8$M?-_zIN{YkH~hQ^s{lv^LsOa>E*_7o%^BWF$I`~u5i1;DIZk((16w7clAg20>4 zqEDCwwjEbpZ0CCGi}{N+37qeqsZ=l8&mWQ0a%W?I@=a+S?z^<+czFB@v>%g13t4{oZ!d{c6T)X;akHI#dgR`z@ zimSPE{yw0hS?;=YUgi66!`o6>{(CoXx%r9nA0s00K;`w?hZgIcfPv-+#J1!GUO`bl zu%s%XqaR$pS`T3C+kP!i>Gx1yv^yAi(ez Date: Mon, 18 May 2026 20:05:27 +0530 Subject: [PATCH 21/23] feat: add project name support to stack configuration, inject JWT role mapper, and move Keycloak provider syncing to initialization --- cli/src/commands/init.ts | 18 ++++++++++++++++-- cli/src/common/config.ts | 2 ++ cli/src/modules/stack/commands/up.ts | 12 ------------ cli/src/modules/stack/services/compose.ts | 9 ++++++++- cli/src/modules/stack/services/realm-init.ts | 19 +++++++++++++++++++ 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/cli/src/commands/init.ts b/cli/src/commands/init.ts index 84639ea..4ea7bd1 100644 --- a/cli/src/commands/init.ts +++ b/cli/src/commands/init.ts @@ -1,8 +1,9 @@ +import crypto from "crypto"; import fs from "fs"; import path from "path"; import ora from "ora"; import {logger} from "../common/logger"; -import {promptConfirm} from "../common/prompt"; +import {promptConfirm, promptInput} from "../common/prompt"; import { projectRoot, POSTKIT_CONFIG_FILE, @@ -18,6 +19,7 @@ import type {CommandOptions} from "../common/types"; import type {PostkitPublicConfig, PostkitSecrets} from "../common/config"; import {scaffoldDbInfra} from "../modules/db/services/scaffold"; import {scaffoldRealmTemplate, DEFAULT_REALM_TEMPLATE_PATH} from "../modules/stack/services/scaffold"; +import {syncKeycloakProviders} from "../modules/stack/services/sync-providers"; // Ephemeral/user-specific files are gitignored; committed migrations and auth state are tracked. // postkit.config.json is safe to commit. @@ -108,6 +110,15 @@ const SCAFFOLD_SECRETS_EXAMPLE: PostkitSecrets = { export async function initCommand(options: CommandOptions): Promise { logger.heading("Postkit Init"); + // Prompt for project name — required + const rawName = await promptInput("Project name:", { + required: true, + force: options.force, + }); + const randomId = crypto.randomBytes(4).toString("hex"); + const projectName = `${rawName.trim().toLowerCase().replace(/\s+/g, "-")}_${randomId}`; + logger.info(`Project ID: ${projectName}`); + const postkitDir = getPostkitDir(); const configFile = getConfigFilePath(); const alreadyInitialized = @@ -169,6 +180,8 @@ export async function initCommand(options: CommandOptions): Promise { fs.mkdirSync(subPath, {recursive: true}); } } + // Copy bundled Keycloak provider JARs from cli/vendor/providers/ + syncKeycloakProviders(); spinner.succeed(".postkit/auth/ directory created"); } @@ -190,7 +203,8 @@ export async function initCommand(options: CommandOptions): Promise { } else { const spinner = ora("Writing config files...").start(); - fs.writeFileSync(configFile, JSON.stringify(SCAFFOLD_PUBLIC_CONFIG, null, 2) + "\n"); + const publicConfig: PostkitPublicConfig = {...SCAFFOLD_PUBLIC_CONFIG, name: projectName}; + fs.writeFileSync(configFile, JSON.stringify(publicConfig, null, 2) + "\n"); const secretsFile = getSecretsFilePath(); fs.writeFileSync(secretsFile, JSON.stringify(SCAFFOLD_SECRETS, null, 2) + "\n"); diff --git a/cli/src/common/config.ts b/cli/src/common/config.ts index 9880d93..6e1c15d 100644 --- a/cli/src/common/config.ts +++ b/cli/src/common/config.ts @@ -117,6 +117,7 @@ export interface StackPublicConfig { } export interface PostkitPublicConfig { + name?: string; db?: DbPublicConfig; auth?: AuthPublicConfig; stack?: StackPublicConfig; @@ -172,6 +173,7 @@ export interface PostkitSecrets { // PostkitConfig interface matching the JSON structure export interface PostkitConfig { + name?: string; db: DbInputConfig; auth: AuthInputConfig; stack?: Record; diff --git a/cli/src/modules/stack/commands/up.ts b/cli/src/modules/stack/commands/up.ts index 259f94a..7936443 100644 --- a/cli/src/modules/stack/commands/up.ts +++ b/cli/src/modules/stack/commands/up.ts @@ -8,7 +8,6 @@ import type {ServiceName} from "../services/compose"; import {waitForAllServices} from "../services/health"; import {applyStackDeploy} from "../services/db-init"; import {readStackIsInitial, setStackInitialized} from "../utils/stack-state"; -import {syncKeycloakProviders} from "../services/sync-providers"; export interface UpOptions extends CommandOptions { wait?: boolean; @@ -71,17 +70,6 @@ export async function upCommand( await applyStackDeploy(config, dbDeploySpinner); } - // Step 8a: Sync Keycloak providers (auth/providers/*/target/*.jar → .postkit/auth/providers/) - if (selected.includes("keycloak")) { - const providerSpinner = ora("Syncing Keycloak providers...").start(); - syncKeycloakProviders(providerSpinner); - if (!providerSpinner.isSpinning) { - // already succeeded/informed above - } else { - providerSpinner.succeed("Keycloak providers dir ready"); - } - } - // Step 8: Start keycloak + postgrest — only after DB migrations are applied const remainingServices = selected.filter((s) => !infraServices.includes(s)); if (remainingServices.length > 0) { diff --git a/cli/src/modules/stack/services/compose.ts b/cli/src/modules/stack/services/compose.ts index 152a55a..6360fe4 100644 --- a/cli/src/modules/stack/services/compose.ts +++ b/cli/src/modules/stack/services/compose.ts @@ -3,6 +3,7 @@ import path from "path"; import type {StackConfig} from "../types/config"; import {getStackDir} from "../utils/stack-config"; import {getProvidersDir} from "./sync-providers"; +import {loadPostkitConfig} from "../../../common/config"; /** All supported service names. */ export const ALL_SERVICES = ["postgres", "keycloak", "postgrest", "traefik"] as const; @@ -52,7 +53,8 @@ export function generateComposeFile( config: StackConfig, services: ServiceName[], ): string { - const sections: string[] = ["services:"]; + const projectName = loadPostkitConfig().name ?? "postkit"; + const sections: string[] = [`name: ${projectName}`, "services:"]; if (services.includes("traefik")) { sections.push(renderTraefik(config)); @@ -155,6 +157,11 @@ function renderKeycloak(config: StackConfig): string { KC_DB_USERNAME: ${pg.user} KC_DB_PASSWORD: ${pg.password} KC_DB_SCHEMA: auth + KC_DB_POOL_INITIAL_SIZE: 1 + KC_DB_POOL_MIN_SIZE: 1 + KC_DB_POOL_MAX_SIZE: 10 + KC_BOOTSTRAP_ADMIN_USERNAME: ${kc.adminUser} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${kc.adminPassword} KEYCLOAK_ADMIN: ${kc.adminUser} KEYCLOAK_ADMIN_PASSWORD: ${kc.adminPassword} volumes: diff --git a/cli/src/modules/stack/services/realm-init.ts b/cli/src/modules/stack/services/realm-init.ts index 5d1059e..1871baf 100644 --- a/cli/src/modules/stack/services/realm-init.ts +++ b/cli/src/modules/stack/services/realm-init.ts @@ -21,6 +21,18 @@ const BUILTIN_CLIENTS = new Set([ "security-admin-console", ]); +// ============================================ +// PostKit default protocol mapper — injected into every non-builtin client +// ============================================ + +const JWT_ROLE_MAPPER = { + name: "JWT Role Mapper", + protocol: "openid-connect", + protocolMapper: "script-primary-role.js", + consentRequired: false, + config: {}, +}; + // ============================================ // Types // ============================================ @@ -84,6 +96,13 @@ export function cleanRealmTemplate( client.serviceAccountRealmRoles = ["anon"]; } + // Inject JWT Role Mapper if not already present + const mappers = (client.protocolMappers ?? []) as Array>; + const hasJwtMapper = mappers.some((m) => m.name === JWT_ROLE_MAPPER.name); + if (!hasJwtMapper) { + client.protocolMappers = [...mappers, JWT_ROLE_MAPPER]; + } + return client; }); From c782d0c13aabd3affe8ac5b67ab49756b2b32754 Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Tue, 19 May 2026 10:41:53 +0530 Subject: [PATCH 22/23] test: add comprehensive unit and e2e test suites for stack management modules and workflows --- .../stack-config-errors.test.ts | 84 ++++ cli/test/e2e/smoke/basic-commands.test.ts | 9 +- cli/test/e2e/smoke/stack-commands.test.ts | 82 ++++ .../e2e/workflows/infra-grants-seeds.test.ts | 2 +- .../e2e/workflows/stack-init-workflow.test.ts | 159 ++++++ .../modules/stack/commands/restart.test.ts | 246 ++++++++++ .../modules/stack/services/compose.test.ts | 230 +++++++++ .../modules/stack/services/db-init.test.ts | 330 +++++++++++++ .../modules/stack/services/realm-init.test.ts | 249 ++++++++++ .../modules/stack/services/scaffold.test.ts | 87 ++++ .../stack/services/sync-providers.test.ts | 159 ++++++ .../modules/stack/utils/stack-config.test.ts | 454 ++++++++++++++++++ .../modules/stack/utils/stack-state.test.ts | 242 ++++++++++ 13 files changed, 2330 insertions(+), 3 deletions(-) create mode 100644 cli/test/e2e/error-handling/stack-config-errors.test.ts create mode 100644 cli/test/e2e/smoke/stack-commands.test.ts create mode 100644 cli/test/e2e/workflows/stack-init-workflow.test.ts create mode 100644 cli/test/modules/stack/commands/restart.test.ts create mode 100644 cli/test/modules/stack/services/compose.test.ts create mode 100644 cli/test/modules/stack/services/db-init.test.ts create mode 100644 cli/test/modules/stack/services/realm-init.test.ts create mode 100644 cli/test/modules/stack/services/scaffold.test.ts create mode 100644 cli/test/modules/stack/services/sync-providers.test.ts create mode 100644 cli/test/modules/stack/utils/stack-config.test.ts create mode 100644 cli/test/modules/stack/utils/stack-state.test.ts diff --git a/cli/test/e2e/error-handling/stack-config-errors.test.ts b/cli/test/e2e/error-handling/stack-config-errors.test.ts new file mode 100644 index 0000000..0c888f6 --- /dev/null +++ b/cli/test/e2e/error-handling/stack-config-errors.test.ts @@ -0,0 +1,84 @@ +import fs from "fs/promises"; +import path from "path"; +import {describe, it, expect, beforeAll, afterAll} from "vitest"; +import {runCli} from "../helpers/cli-runner"; +import {createTestProject, cleanupTestProject, type TestProject} from "../helpers/test-project"; + +/** + * Write a minimal stub docker-compose.yml into the project's stack directory + * so that commands which check for the compose file's existence can proceed + * past that guard and reach subsequent validation logic (e.g., service name checks). + */ +async function writeStubComposeFile(project: TestProject): Promise { + const stackDir = path.join(project.postkitDir, "stack"); + await fs.mkdir(stackDir, {recursive: true}); + const stub = [ + "name: postkit-test", + "services:", + " postgres:", + " image: postgres:16-alpine", + ].join("\n") + "\n"; + await fs.writeFile(path.join(stackDir, "docker-compose.yml"), stub, "utf-8"); +} + +describe("Error handling — stack commands with initialized project (no Docker)", () => { + let project: TestProject; + + beforeAll(async () => { + // Create a project with config but no active Docker stack + project = await createTestProject({ + localDbUrl: "postgres://localhost:5432/test", + }); + // Place a stub compose file so restart/down/status reach their post-file-check logic + await writeStubComposeFile(project); + }); + + afterAll(async () => { + await cleanupTestProject(project); + }); + + it("stack restart with unknown service name exits non-zero and mentions 'Unknown service'", async () => { + const result = await runCli( + ["stack", "restart", "unknown-service"], + {cwd: project.rootDir}, + ); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/Unknown service/i); + expect(output).toContain("unknown-service"); + }); + + it("stack restart with mixed valid and unknown services reports the unknown service", async () => { + const result = await runCli( + ["stack", "restart", "postgres", "keycloak", "unknown-svc"], + {cwd: project.rootDir}, + ); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("unknown-svc"); + }); + + it("stack down with no running stack exits gracefully (no crash)", async () => { + const result = await runCli( + ["stack", "down"], + {cwd: project.rootDir}, + ); + // Must not emit a raw JS stack trace regardless of exit code + const output = result.stdout + result.stderr; + expect(output).not.toContain("at Object."); + expect(output).not.toContain("TypeError:"); + expect(output).not.toContain("ReferenceError:"); + }); + + it("stack status with no running stack fails gracefully with a helpful message", async () => { + const result = await runCli( + ["stack", "status"], + {cwd: project.rootDir}, + ); + // status either fails (compose file exists but docker not running) or exits non-zero + // The key assertion: no raw stack trace, message is user-facing + const output = result.stdout + result.stderr; + expect(output).not.toContain("TypeError:"); + expect(output).not.toContain("at Object."); + }); +}); diff --git a/cli/test/e2e/smoke/basic-commands.test.ts b/cli/test/e2e/smoke/basic-commands.test.ts index 2f632dc..17b5ae7 100644 --- a/cli/test/e2e/smoke/basic-commands.test.ts +++ b/cli/test/e2e/smoke/basic-commands.test.ts @@ -195,8 +195,13 @@ describe("init command — detailed tests (no Docker)", () => { path.join(tmpDir, "postkit.config.json"), "utf-8", ); - // Config should be identical after second init - expect(firstConfig).toBe(secondConfig); + // Non-name fields should be identical after second init + // (name includes a random suffix so it changes each run) + const cfg1 = JSON.parse(firstConfig) as Record; + const cfg2 = JSON.parse(secondConfig) as Record; + delete cfg1.name; + delete cfg2.name; + expect(cfg1).toEqual(cfg2); } finally { await cleanupDir(tmpDir); } diff --git a/cli/test/e2e/smoke/stack-commands.test.ts b/cli/test/e2e/smoke/stack-commands.test.ts new file mode 100644 index 0000000..e848003 --- /dev/null +++ b/cli/test/e2e/smoke/stack-commands.test.ts @@ -0,0 +1,82 @@ +import {describe, it, expect} from "vitest"; +import {runCli} from "../helpers/cli-runner"; +import {createEmptyDir, cleanupDir} from "../helpers/test-project"; + +describe("Smoke tests — stack subcommand help (no Docker)", () => { + it("stack --help lists all subcommands", async () => { + const result = await runCli(["stack", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("up"); + expect(output).toContain("down"); + expect(output).toContain("restart"); + expect(output).toContain("status"); + expect(output).toContain("logs"); + }); + + it("stack up --help shows --wait and --keys flags", async () => { + const result = await runCli(["stack", "up", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + expect(output).toContain("--no-wait"); + expect(output).toContain("--no-keys"); + }); + + it("stack restart --help shows variadic [services...] argument", async () => { + const result = await runCli(["stack", "restart", "--help"]); + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + // Commander renders variadic arguments as [services...] + expect(output).toContain("services"); + }); +}); + +describe("Smoke tests — stack in uninitialized directory (no Docker)", () => { + it("stack up fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "up"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack status fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "status"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack restart fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "restart"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); + + it("stack down fails with not-initialized error in empty dir", async () => { + const tmpDir = await createEmptyDir(); + try { + const result = await runCli(["stack", "down"], {cwd: tmpDir}); + expect(result.exitCode).not.toBe(0); + const output = result.stdout + result.stderr; + expect(output).toMatch(/not initialized|Config file not found/i); + } finally { + await cleanupDir(tmpDir); + } + }); +}); diff --git a/cli/test/e2e/workflows/infra-grants-seeds.test.ts b/cli/test/e2e/workflows/infra-grants-seeds.test.ts index 351bb48..5c0544a 100644 --- a/cli/test/e2e/workflows/infra-grants-seeds.test.ts +++ b/cli/test/e2e/workflows/infra-grants-seeds.test.ts @@ -45,7 +45,7 @@ describe("Infra and seeds workflow", () => { afterAll(async () => { // Clean up session - await runCli(["db", "abort", "--force"], {cwd: project.rootDir}).catch(() => {}); + if (project) await runCli(["db", "abort", "--force"], {cwd: project.rootDir}).catch(() => {}); if (project) await cleanupTestProject(project); if (db) await stopPostgres(db); }); diff --git a/cli/test/e2e/workflows/stack-init-workflow.test.ts b/cli/test/e2e/workflows/stack-init-workflow.test.ts new file mode 100644 index 0000000..46a1a6f --- /dev/null +++ b/cli/test/e2e/workflows/stack-init-workflow.test.ts @@ -0,0 +1,159 @@ +import {describe, it, expect, beforeAll, afterAll} from "vitest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import {runCli} from "../helpers/cli-runner"; + +/** + * Stack Init Workflow + * + * Tests the `postkit init` command's scaffold outputs: + * directory structure, config files, infra SQL, realm template, and gitignore. + * + * No Docker required — all assertions are filesystem-based. + */ +describe("stack init workflow", () => { + let rootDir: string; + + beforeAll(async () => { + rootDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "postkit-e2e-init-"), + ); + + // --force skips all interactive prompts so the test runs non-interactively. + // The project name prompt returns "" with --force, yielding name "_". + const result = await runCli(["init", "--force"], {cwd: rootDir}); + + if (result.exitCode !== 0) { + throw new Error( + `postkit init --force failed (exit ${result.exitCode}):\n${result.stderr || result.stdout}`, + ); + } + }); + + afterAll(async () => { + if (rootDir) { + await fs.promises.rm(rootDir, {recursive: true, force: true}); + } + }); + + // ── Directory structure ─────────────────────────────────────────────── + + it("postkit init creates .postkit/auth/providers/ directory", () => { + const providersDir = path.join(rootDir, ".postkit", "auth", "providers"); + expect(fs.existsSync(providersDir)).toBe(true); + expect(fs.statSync(providersDir).isDirectory()).toBe(true); + }); + + it("postkit init creates .postkit/stack/ directory", () => { + const stackDir = path.join(rootDir, ".postkit", "stack"); + expect(fs.existsSync(stackDir)).toBe(true); + expect(fs.statSync(stackDir).isDirectory()).toBe(true); + }); + + // ── Infra SQL files ─────────────────────────────────────────────────── + + it("postkit init creates db/infra/001_roles.sql with IF NOT EXISTS pattern", () => { + const rolesFile = path.join(rootDir, "db", "infra", "001_roles.sql"); + expect(fs.existsSync(rolesFile)).toBe(true); + const content = fs.readFileSync(rolesFile, "utf-8"); + expect(content).toContain("IF NOT EXISTS"); + }); + + it("postkit init creates db/infra/002_schemas.sql with public/auth/storage schemas", () => { + const schemasFile = path.join(rootDir, "db", "infra", "002_schemas.sql"); + expect(fs.existsSync(schemasFile)).toBe(true); + const content = fs.readFileSync(schemasFile, "utf-8"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS auth;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS public;"); + expect(content).toContain("CREATE SCHEMA IF NOT EXISTS storage;"); + }); + + // ── Realm template ──────────────────────────────────────────────────── + + it("postkit init creates realm template at configured path", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + stack?: {keycloak?: {realmTemplate?: string}}; + }; + + const realmTemplatePath = config?.stack?.keycloak?.realmTemplate; + expect(realmTemplatePath).toBeTruthy(); + + const realmFile = path.join(rootDir, realmTemplatePath as string); + expect(fs.existsSync(realmFile)).toBe(true); + }); + + // ── postkit.config.json ─────────────────────────────────────────────── + + it("generated postkit.config.json has 'name' field matching _ pattern", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + expect(config.name).toBeDefined(); + // With --force and no default, slug is empty → name is "_<8hex>" + // Pattern allows optional slug prefix: [a-z0-9-]*_[0-9a-f]{8} + expect(config.name).toMatch(/^[a-z0-9-]*_[0-9a-f]{8}$/); + }); + + it("postkit.config.json name matches pattern: lowercase-slug_[0-9a-f]{8}", () => { + const configPath = path.join(rootDir, "postkit.config.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + // The hex suffix is always exactly 8 characters (4 random bytes) + const parts = (config.name as string).split("_"); + const hexSuffix = parts[parts.length - 1]; + expect(hexSuffix).toMatch(/^[0-9a-f]{8}$/); + }); + + // ── Secrets files ───────────────────────────────────────────────────── + + it("postkit init creates postkit.secrets.example.json", () => { + const exampleFile = path.join(rootDir, "postkit.secrets.example.json"); + expect(fs.existsSync(exampleFile)).toBe(true); + // Should be valid JSON with expected top-level keys + const content = JSON.parse(fs.readFileSync(exampleFile, "utf-8")) as Record; + expect(content).toHaveProperty("db"); + expect(content).toHaveProperty("auth"); + }); + + // ── .gitignore ──────────────────────────────────────────────────────── + + it("postkit init adds postkit.secrets.json to .gitignore", () => { + const gitignorePath = path.join(rootDir, ".gitignore"); + expect(fs.existsSync(gitignorePath)).toBe(true); + const content = fs.readFileSync(gitignorePath, "utf-8"); + expect(content).toContain("postkit.secrets.json"); + }); + + // ── Idempotency ─────────────────────────────────────────────────────── + + it("running postkit init a second time with --force overwrites config", async () => { + // Capture the name from the first run + const configPath = path.join(rootDir, "postkit.config.json"); + const firstConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + const firstName = firstConfig.name; + + // Second init with --force should succeed and regenerate the name + const result = await runCli(["init", "--force"], {cwd: rootDir}); + expect(result.exitCode).toBe(0); + + const secondConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as { + name?: string; + }; + expect(secondConfig.name).toBeDefined(); + expect(secondConfig.name).toMatch(/^[a-z0-9-]*_[0-9a-f]{8}$/); + + // The random ID will almost certainly differ — but regardless the config is valid + // (very low probability both runs produce identical 4-byte random values) + // Just verify the name field exists and is properly shaped + expect(secondConfig.name).not.toBe(undefined); + // Note: firstName !== secondConfig.name in the vast majority of cases + // (1 in 4 billion chance of collision) — no strict inequality assertion here + void firstName; + }); +}); diff --git a/cli/test/modules/stack/commands/restart.test.ts b/cli/test/modules/stack/commands/restart.test.ts new file mode 100644 index 0000000..ca4c1ef --- /dev/null +++ b/cli/test/modules/stack/commands/restart.test.ts @@ -0,0 +1,246 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — declared BEFORE importing the module under test +// --------------------------------------------------------------------------- + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + }, +})); + +vi.mock("../../../../src/modules/stack/services/docker-compose", () => ({ + composeRestart: vi.fn(), +})); + +vi.mock("../../../../src/modules/stack/utils/stack-config", () => ({ + getStackConfig: vi.fn(), + getComposeFilePath: vi.fn(() => "/project/.postkit/stack/docker-compose.yml"), +})); + +vi.mock("../../../../src/modules/stack/services/health", () => ({ + waitForAllServices: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../../src/common/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + heading: vi.fn(), + }, +})); + +// ora must return a chainable spinner object +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + text: "", + })), +})); + +import fs from "fs"; +import {composeRestart} from "../../../../src/modules/stack/services/docker-compose"; +import {getStackConfig, getComposeFilePath} from "../../../../src/modules/stack/utils/stack-config"; +import {logger} from "../../../../src/common/logger"; +import {restartCommand} from "../../../../src/modules/stack/commands/restart"; +import {PostkitError} from "../../../../src/common/errors"; +import type {CommandOptions} from "../../../../src/common/types"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const DEFAULT_OPTIONS: CommandOptions = {verbose: false, dryRun: false, json: false}; + +function makeMockStackConfig(): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + }; +} + +const SUCCESS_RESULT = {stdout: "", stderr: "", exitCode: 0}; +const FAILURE_RESULT = {stdout: "", stderr: "restart failed", exitCode: 1}; + +// --------------------------------------------------------------------------- +// restartCommand() +// --------------------------------------------------------------------------- + +describe("restartCommand()", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Compose file exists by default + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(getComposeFilePath).mockReturnValue( + "/project/.postkit/stack/docker-compose.yml", + ); + vi.mocked(getStackConfig).mockReturnValue(makeMockStackConfig()); + vi.mocked(composeRestart).mockResolvedValue(SUCCESS_RESULT); + }); + + describe("when compose file does not exist", () => { + it("throws PostkitError when no stack found", async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + await expect(restartCommand(DEFAULT_OPTIONS)).rejects.toThrow(PostkitError); + await expect(restartCommand(DEFAULT_OPTIONS)).rejects.toThrow("No stack found"); + }); + }); + + describe("with unknown services", () => { + it("throws PostkitError with clear message for unknown service name", async () => { + await expect( + restartCommand(DEFAULT_OPTIONS, ["unknown-service"]), + ).rejects.toThrow(PostkitError); + await expect( + restartCommand(DEFAULT_OPTIONS, ["unknown-service"]), + ).rejects.toThrow("Unknown service(s): unknown-service"); + }); + + it("includes available services in error hint", async () => { + let thrown: PostkitError | undefined; + try { + await restartCommand(DEFAULT_OPTIONS, ["bad-service"]); + } catch (e) { + thrown = e as PostkitError; + } + expect(thrown).toBeInstanceOf(PostkitError); + expect(thrown!.hint).toContain("Available services"); + }); + }); + + describe("restarting all services", () => { + it("restarts all services when no service args provided", async () => { + await restartCommand(DEFAULT_OPTIONS, []); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + undefined, + ); + }); + + it("calls composeRestart with the compose file path", async () => { + await restartCommand(DEFAULT_OPTIONS); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + undefined, + ); + }); + }); + + describe("restarting specific services", () => { + it("restarts only specified services when args given", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres"]); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + ["postgres"], + ); + }); + + it("passes multiple specified services to composeRestart", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres", "keycloak"]); + + expect(composeRestart).toHaveBeenCalledWith( + "/project/.postkit/stack/docker-compose.yml", + ["postgres", "keycloak"], + ); + }); + + it("accepts all valid service names", async () => { + const validServices = ["postgres", "keycloak", "postgrest", "traefik"]; + for (const svc of validServices) { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(getStackConfig).mockReturnValue(makeMockStackConfig()); + vi.mocked(composeRestart).mockResolvedValue(SUCCESS_RESULT); + + await expect(restartCommand(DEFAULT_OPTIONS, [svc])).resolves.not.toThrow(); + } + }); + }); + + describe("dry-run mode", () => { + it("logs intent without calling composeRestart", async () => { + await restartCommand({...DEFAULT_OPTIONS, dryRun: true}); + + expect(composeRestart).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Dry run")); + }); + + it("logs the services that would be restarted", async () => { + await restartCommand({...DEFAULT_OPTIONS, dryRun: true}, ["postgres"]); + + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("postgres")); + }); + }); + + describe("on non-zero exit from composeRestart", () => { + it("does not throw but reports failure via spinner", async () => { + vi.mocked(composeRestart).mockResolvedValue(FAILURE_RESULT); + + // Should not throw even when exitCode !== 0 + await expect(restartCommand(DEFAULT_OPTIONS)).resolves.not.toThrow(); + }); + + it("logs the stderr output on failure", async () => { + vi.mocked(composeRestart).mockResolvedValue(FAILURE_RESULT); + + await restartCommand(DEFAULT_OPTIONS); + + expect(logger.error).toHaveBeenCalledWith("restart failed"); + }); + }); + + describe("on successful restart", () => { + it("calls getStackConfig for health check after restart", async () => { + await restartCommand(DEFAULT_OPTIONS, ["postgres"]); + + expect(getStackConfig).toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/services/compose.test.ts b/cli/test/modules/stack/services/compose.test.ts new file mode 100644 index 0000000..d936f8a --- /dev/null +++ b/cli/test/modules/stack/services/compose.test.ts @@ -0,0 +1,230 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + loadPostkitConfig: vi.fn(), + projectRoot: "/project", + cliRoot: "/cli", + getPostkitAuthDir: vi.fn(() => "/project/.postkit/auth"), +})); + +vi.mock("../../../../src/modules/stack/utils/stack-config", () => ({ + getStackDir: vi.fn(() => "/project/.postkit/stack"), +})); + +vi.mock("../../../../src/modules/stack/services/sync-providers", () => ({ + getProvidersDir: vi.fn(() => "/project/.postkit/auth/providers"), +})); + +vi.mock("fs", () => ({ + default: { + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +import {loadPostkitConfig} from "../../../../src/common/config"; +import {getProvidersDir} from "../../../../src/modules/stack/services/sync-providers"; +import { + getSelectedServices, + generateComposeFile, + ALL_SERVICES, +} from "../../../../src/modules/stack/services/compose"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +function makeConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:${pgVersion}-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "admin-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + ...overrides, + }; +} + +describe("compose", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(loadPostkitConfig).mockReturnValue({ + name: "myproject", + db: {} as any, + auth: {} as any, + }); + vi.mocked(getProvidersDir).mockReturnValue("/project/.postkit/auth/providers"); + }); + + describe("getSelectedServices()", () => { + it("returns all enabled services when no requested services provided", () => { + const config = makeConfig(); + const result = getSelectedServices(config, []); + // All 4 services are enabled by default + expect(result).toEqual(expect.arrayContaining(["postgres", "keycloak", "postgrest", "traefik"])); + expect(result).toHaveLength(4); + }); + + it("auto-adds postgres and traefik when keycloak is requested", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["keycloak"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("keycloak"); + }); + + it("auto-adds postgres and traefik when postgrest is requested", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["postgrest"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("postgrest"); + }); + + it("explicit keycloak results in postgres + traefik included", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["keycloak"]); + expect(result).toContain("postgres"); + expect(result).toContain("traefik"); + expect(result).toContain("keycloak"); + }); + + it("throws on unknown service name", () => { + const config = makeConfig(); + expect(() => getSelectedServices(config, ["unknown-service"])).toThrow( + /Unknown service.*unknown-service/, + ); + }); + + it("returns only postgres when only postgres requested (no dep services)", () => { + const config = makeConfig(); + const result = getSelectedServices(config, ["postgres"]); + expect(result).toEqual(["postgres"]); + }); + + it("filters out disabled services when no requested services provided", () => { + const config = makeConfig({ + postgrest: { + image: "postgrest/postgrest:latest", + enabled: false, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + }); + const result = getSelectedServices(config, []); + // postgrest is disabled, but keycloak is still enabled so traefik/postgres get added + expect(result).not.toContain("postgrest"); + }); + }); + + describe("generateComposeFile()", () => { + it("output includes 'name: ' line", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + name: "myapp", + db: {} as any, + auth: {} as any, + }); + const config = makeConfig(); + const services = ALL_SERVICES.slice() as any; + const output = generateComposeFile(config, services); + expect(output).toMatch(/^name: myapp/m); + }); + + it("uses 'postkit' as project name when config.name is undefined", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + db: {} as any, + auth: {} as any, + }); + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toMatch(/^name: postkit/m); + }); + + it("output includes all 4 service blocks when all services selected", () => { + const config = makeConfig(); + const services = ALL_SERVICES.slice() as any; + const output = generateComposeFile(config, services); + expect(output).toContain(" postgres:"); + expect(output).toContain(" keycloak:"); + expect(output).toContain(" postgrest:"); + expect(output).toContain(" traefik:"); + }); + + it("network block has explicit 'name:' field", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain("name: postkit-net"); + }); + + it("renderKeycloak includes KC_DB_SCHEMA: auth", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("KC_DB_SCHEMA: auth"); + }); + + it("renderKeycloak includes KC_DB_POOL_MIN_SIZE and KC_DB_POOL_MAX_SIZE", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("KC_DB_POOL_MIN_SIZE:"); + expect(output).toContain("KC_DB_POOL_MAX_SIZE:"); + }); + + it("renderKeycloak includes providers volume mount", () => { + vi.mocked(getProvidersDir).mockReturnValue("/project/.postkit/auth/providers"); + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("/project/.postkit/auth/providers:/opt/keycloak/providers"); + }); + + it("only includes requested service blocks", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain(" postgres:"); + expect(output).not.toContain(" keycloak:"); + expect(output).not.toContain(" postgrest:"); + expect(output).not.toContain(" traefik:"); + }); + + it("volumes section includes postgres volume when postgres selected", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["postgres"] as any); + expect(output).toContain("postkit-pgdata:"); + }); + + it("volumes section includes keycloak volume when keycloak selected", () => { + const config = makeConfig(); + const output = generateComposeFile(config, ["keycloak", "postgres", "traefik"] as any); + expect(output).toContain("postkit-keycloak-data:"); + }); + }); +}); diff --git a/cli/test/modules/stack/services/db-init.test.ts b/cli/test/modules/stack/services/db-init.test.ts new file mode 100644 index 0000000..b62e975 --- /dev/null +++ b/cli/test/modules/stack/services/db-init.test.ts @@ -0,0 +1,330 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// pg mock — Client must be mockable as a constructor (new Client(...)). +// Vitest requires a real function (not arrow) when called with `new`. +// We use a module-level mock object so its methods can be reset in beforeEach. +// --------------------------------------------------------------------------- +const mockClient = { + connect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({rows: [], rowCount: 0}), + end: vi.fn().mockResolvedValue(undefined), +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +vi.mock("pg", () => ({ + // Use a regular function (not arrow) so `new Client(...)` works. + // The function returns mockClient from the outer scope via closure. + Client: vi.fn(function MockClient() { + return mockClient; + }), +})); + +vi.mock("ora", () => ({ + default: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + text: "", + })), +})); + +vi.mock("../../../../src/modules/db/services/infra-generator", () => ({ + applyInfraStep: vi.fn(), +})); + +vi.mock("../../../../src/modules/db/services/dbmate", () => ({ + runCommittedMigrate: vi.fn(), +})); + +vi.mock("../../../../src/modules/db/services/seed-generator", () => ({ + applySeedsStep: vi.fn(), +})); + +import {Client} from "pg"; +import ora from "ora"; +import {applyInfraStep} from "../../../../src/modules/db/services/infra-generator"; +import {runCommittedMigrate} from "../../../../src/modules/db/services/dbmate"; +import {applySeedsStep} from "../../../../src/modules/db/services/seed-generator"; +import {buildPgUrl, applyStackDeploy} from "../../../../src/modules/stack/services/db-init"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +function makeConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "pguser", + password: "pgpass", + database: "testdb", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "admin-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + ...overrides, + }; +} + +function makeSpinner() { + return { + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + text: "", + } as unknown as ReturnType; +} + +describe("db-init", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + // Restore default happy-path behaviour after clearAllMocks() wipes implementations. + // Re-apply the constructor factory (must use regular function, not arrow). + vi.mocked(Client).mockImplementation(function MockClient() { + return mockClient as any; + } as any); + mockClient.connect.mockResolvedValue(undefined); + mockClient.query.mockResolvedValue({rows: [], rowCount: 0}); + mockClient.end.mockResolvedValue(undefined); + }); + + describe("buildPgUrl()", () => { + it("builds correct postgres URL from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toBe("postgres://pguser:pgpass@localhost:25432/testdb"); + }); + + it("URL-encodes special characters in password", () => { + const config = makeConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "pguser", + password: "p@ss#word!", + database: "testdb", + pgVersion: 16, + volume: "postkit-pgdata", + }, + }); + const url = buildPgUrl(config); + // encodeURIComponent encodes @, #, ! + expect(url).toContain("p%40ss%23word!"); + expect(url).not.toContain("p@ss"); + }); + + it("uses localhost as host", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain("@localhost:"); + }); + + it("includes port from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain(":25432/"); + }); + + it("includes database name from config", () => { + const config = makeConfig(); + const url = buildPgUrl(config); + expect(url).toContain("/testdb"); + }); + }); + + describe("applyStackDeploy()", () => { + it("connects to postgres and creates postkit schema + table", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(mockClient.connect).toHaveBeenCalledOnce(); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("CREATE SCHEMA IF NOT EXISTS postkit"), + ); + }); + + it("closes client connection after schema query", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(mockClient.end).toHaveBeenCalled(); + }); + + it("calls applyInfraStep for phase 1", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(applyInfraStep).toHaveBeenCalledOnce(); + expect(applyInfraStep).toHaveBeenCalledWith( + spinner, + expect.stringContaining("postgres://"), + "stack", + ); + }); + + it("calls runCommittedMigrate for phase 2", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(runCommittedMigrate).toHaveBeenCalledOnce(); + expect(runCommittedMigrate).toHaveBeenCalledWith( + expect.stringContaining("postgres://"), + ); + }); + + it("calls applySeedsStep for phase 3", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await applyStackDeploy(config, spinner); + + expect(applySeedsStep).toHaveBeenCalledOnce(); + expect(applySeedsStep).toHaveBeenCalledWith( + spinner, + expect.stringContaining("postgres://"), + "stack", + ); + }); + + it("retries pg connection on failure and succeeds on later attempt", async () => { + // First 2 attempts fail, 3rd succeeds — all using the same shared mockClient object. + // We override connect to fail twice then succeed. + let callCount = 0; + mockClient.connect.mockImplementation(() => { + callCount++; + if (callCount < 3) { + return Promise.reject(new Error("ECONNREFUSED")); + } + return Promise.resolve(); + }); + + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({success: true, output: ""}); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + vi.useFakeTimers(); + const config = makeConfig(); + const spinner = makeSpinner(); + + const deployPromise = applyStackDeploy(config, spinner); + await vi.runAllTimersAsync(); + await deployPromise; + + // connect was called at least 3 times (2 failures + 1 success) + expect(mockClient.connect).toHaveBeenCalledTimes(3); + }); + + it("throws after all retries exhausted", async () => { + mockClient.connect.mockRejectedValue(new Error("ECONNREFUSED")); + + vi.useFakeTimers(); + const config = makeConfig(); + const spinner = makeSpinner(); + + // Capture the error immediately so no unhandled rejection leaks out + let caughtError: unknown; + const deployPromise = applyStackDeploy(config, spinner).catch((err) => { + caughtError = err; + }); + await vi.runAllTimersAsync(); + await deployPromise; + + expect(caughtError).toBeDefined(); + expect((caughtError as Error).message).toBeTruthy(); + }); + + it("does not throw when runCommittedMigrate reports no migration files found", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({ + success: false, + output: "no migration files found", + }); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).resolves.not.toThrow(); + }); + + it("throws when runCommittedMigrate fails with non-trivial error", async () => { + vi.mocked(applyInfraStep).mockResolvedValue(undefined); + vi.mocked(runCommittedMigrate).mockResolvedValue({ + success: false, + output: "syntax error at or near 'CREAT'", + }); + vi.mocked(applySeedsStep).mockResolvedValue(undefined); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).rejects.toThrow(/Migration failed/); + }); + + it("closes the pg client even when query throws", async () => { + mockClient.query.mockRejectedValue(new Error("query error")); + + const config = makeConfig(); + const spinner = makeSpinner(); + + await expect(applyStackDeploy(config, spinner)).rejects.toThrow("query error"); + expect(mockClient.end).toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/services/realm-init.test.ts b/cli/test/modules/stack/services/realm-init.test.ts new file mode 100644 index 0000000..f130cc0 --- /dev/null +++ b/cli/test/modules/stack/services/realm-init.test.ts @@ -0,0 +1,249 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", +})); + +vi.mock("../../../../src/common/shell", () => ({ + runSpawnCommand: vi.fn(), +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +vi.mock("fs/promises", () => ({ + mkdtemp: vi.fn(), + writeFile: vi.fn(), + rm: vi.fn(), +})); + +import {cleanRealmTemplate} from "../../../../src/modules/stack/services/realm-init"; + +const JWT_ROLE_MAPPER_NAME = "JWT Role Mapper"; +const BUILTIN_CLIENT_IDS = [ + "account", + "account-console", + "admin-cli", + "broker", + "realm-management", + "security-admin-console", +]; + +function makeRawRealm(overrides: Record = {}): Record { + return { + id: "some-uuid-123", + realm: "original-realm", + enabled: true, + clients: [], + roles: {realm: [], client: {}}, + ...overrides, + }; +} + +describe("cleanRealmTemplate()", () => { + it("sets realm to provided name", () => { + const raw = makeRawRealm(); + const result = cleanRealmTemplate(raw, "my-realm"); + expect(result.realm).toBe("my-realm"); + }); + + it("deletes top-level id", () => { + const raw = makeRawRealm({id: "some-uuid"}); + const result = cleanRealmTemplate(raw, "test"); + expect(result.id).toBeUndefined(); + }); + + it("does not mutate the original input", () => { + const raw = makeRawRealm({id: "original-id"}); + cleanRealmTemplate(raw, "test"); + expect(raw.id).toBe("original-id"); + }); + + describe("client filtering", () => { + it("filters out all builtin clients", () => { + const builtinClients = BUILTIN_CLIENT_IDS.map((clientId) => ({clientId, id: "id-" + clientId})); + const userClient = {clientId: "my-app", id: "user-id"}; + const raw = makeRawRealm({clients: [...builtinClients, userClient]}); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array<{clientId: string}>; + const ids = clients.map((c) => c.clientId); + for (const builtin of BUILTIN_CLIENT_IDS) { + expect(ids).not.toContain(builtin); + } + }); + + it("preserves non-builtin clients", () => { + const raw = makeRawRealm({ + clients: [ + {clientId: "my-app", id: "user-id"}, + {clientId: "account", id: "builtin-id"}, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array<{clientId: string}>; + expect(clients.map((c) => c.clientId)).toContain("my-app"); + }); + + it("removes id, secret, and registrationAccessToken from non-builtin clients", () => { + const raw = makeRawRealm({ + clients: [ + { + clientId: "my-app", + id: "some-id", + secret: "super-secret", + registrationAccessToken: "reg-token", + }, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.id).toBeUndefined(); + expect(clients[0]!.secret).toBeUndefined(); + expect(clients[0]!.registrationAccessToken).toBeUndefined(); + }); + + it("removes client.secret.creation.time from attributes", () => { + const raw = makeRawRealm({ + clients: [ + { + clientId: "my-app", + attributes: { + "client.secret.creation.time": "12345678", + "other-attr": "keep-me", + }, + }, + ], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const attrs = clients[0]!.attributes as Record; + expect(attrs["client.secret.creation.time"]).toBeUndefined(); + expect(attrs["other-attr"]).toBe("keep-me"); + }); + + it("sets serviceAccountRealmRoles for supabase_service client", () => { + const raw = makeRawRealm({ + clients: [{clientId: "supabase_service"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.serviceAccountRealmRoles).toEqual(["service_role", "app_user"]); + }); + + it("sets serviceAccountRealmRoles for anon client", () => { + const raw = makeRawRealm({ + clients: [{clientId: "anon"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + expect(clients[0]!.serviceAccountRealmRoles).toEqual(["anon"]); + }); + + it("injects JWT Role Mapper when absent", () => { + const raw = makeRawRealm({ + clients: [{clientId: "my-app", protocolMappers: []}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + expect(mappers.some((m) => m.name === JWT_ROLE_MAPPER_NAME)).toBe(true); + }); + + it("does NOT re-inject JWT Role Mapper when already present (idempotent)", () => { + const existingMapper = {name: JWT_ROLE_MAPPER_NAME, protocol: "openid-connect"}; + const raw = makeRawRealm({ + clients: [{clientId: "my-app", protocolMappers: [existingMapper]}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + const jwtMappers = mappers.filter((m) => m.name === JWT_ROLE_MAPPER_NAME); + expect(jwtMappers).toHaveLength(1); + }); + + it("injects JWT Role Mapper when protocolMappers is absent", () => { + const raw = makeRawRealm({ + clients: [{clientId: "my-app"}], + }); + const result = cleanRealmTemplate(raw, "test"); + const clients = result.clients as Array>; + const mappers = clients[0]!.protocolMappers as Array<{name: string}>; + expect(mappers.some((m) => m.name === JWT_ROLE_MAPPER_NAME)).toBe(true); + }); + }); + + describe("realm roles", () => { + it("creates admin realm role when roles array is empty", () => { + const raw = makeRawRealm({roles: {realm: [], client: {}}}); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + expect(roles.some((r) => r.name === "admin")).toBe(true); + }); + + it("does NOT duplicate admin role when already present", () => { + const raw = makeRawRealm({ + roles: { + realm: [{name: "admin", id: "admin-id", composite: false, clientRole: false}], + client: {}, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + const adminRoles = roles.filter((r) => r.name === "admin"); + expect(adminRoles).toHaveLength(1); + }); + + it("strips id from every realm role", () => { + const raw = makeRawRealm({ + roles: { + realm: [ + {name: "admin", id: "admin-id"}, + {name: "user", id: "user-id"}, + ], + client: {}, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array>}).realm; + for (const role of roles) { + expect(role.id).toBeUndefined(); + } + }); + + it("removes builtin keys from roles.client", () => { + const raw = makeRawRealm({ + roles: { + realm: [], + client: { + account: [{name: "manage-account"}], + "realm-management": [{name: "manage-users"}], + "my-app": [{name: "app-role"}], + }, + }, + }); + const result = cleanRealmTemplate(raw, "test"); + const clientRoles = (result.roles as {client: Record}).client; + expect(clientRoles["account"]).toBeUndefined(); + expect(clientRoles["realm-management"]).toBeUndefined(); + expect(clientRoles["my-app"]).toBeDefined(); + }); + + it("initializes realm roles as array when missing from input", () => { + const raw: Record = { + id: "uuid", + realm: "test", + roles: {}, + }; + const result = cleanRealmTemplate(raw, "test"); + const roles = (result.roles as {realm: Array<{name: string}>}).realm; + expect(Array.isArray(roles)).toBe(true); + }); + }); +}); diff --git a/cli/test/modules/stack/services/scaffold.test.ts b/cli/test/modules/stack/services/scaffold.test.ts new file mode 100644 index 0000000..e0fddc0 --- /dev/null +++ b/cli/test/modules/stack/services/scaffold.test.ts @@ -0,0 +1,87 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +import fs from "fs"; +import {scaffoldRealmTemplate} from "../../../../src/modules/stack/services/scaffold"; + +describe("scaffoldRealmTemplate()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates the realm file and returns true when file does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + const result = scaffoldRealmTemplate(); + + expect(result).toBe(true); + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + }); + + it("creates parent directories if missing", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining(".postkit/auth/realm"), + {recursive: true}, + ); + }); + + it("writes file at path containing DEFAULT_REALM_TEMPLATE_PATH segments", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]!; + const writtenPath = writeCall[0] as string; + expect(writtenPath).toContain(".postkit"); + expect(writtenPath).toContain("auth"); + expect(writtenPath).toContain("realm"); + expect(writtenPath).toContain("postkit.json"); + }); + + it("writes valid JSON content containing realm template structure", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + scaffoldRealmTemplate(); + + const writeCall = vi.mocked(fs.writeFileSync).mock.calls[0]!; + const content = writeCall[1] as string; + const parsed = JSON.parse(content); + expect(parsed).toHaveProperty("realm"); + expect(parsed).toHaveProperty("enabled"); + expect(parsed).toHaveProperty("clients"); + expect(parsed).toHaveProperty("roles"); + }); + + it("skips write and returns false when file already exists", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = scaffoldRealmTemplate(); + + expect(result).toBe(false); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("always calls mkdirSync even when file exists", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + scaffoldRealmTemplate(); + + expect(fs.mkdirSync).toHaveBeenCalledOnce(); + }); +}); diff --git a/cli/test/modules/stack/services/sync-providers.test.ts b/cli/test/modules/stack/services/sync-providers.test.ts new file mode 100644 index 0000000..c55b59f --- /dev/null +++ b/cli/test/modules/stack/services/sync-providers.test.ts @@ -0,0 +1,159 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +vi.mock("../../../../src/common/config", () => ({ + projectRoot: "/project", + cliRoot: "/cli", + getPostkitAuthDir: vi.fn(() => "/project/.postkit/auth"), +})); + +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), + copyFileSync: vi.fn(), + }, +})); + +import fs from "fs"; +import {getProvidersDir, syncKeycloakProviders} from "../../../../src/modules/stack/services/sync-providers"; +import {getPostkitAuthDir} from "../../../../src/common/config"; + +describe("sync-providers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getProvidersDir()", () => { + it("returns a path ending in .postkit/auth/providers", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + const dir = getProvidersDir(); + expect(dir).toMatch(/\.postkit[\\/]auth[\\/]providers$/); + }); + }); + + describe("syncKeycloakProviders()", () => { + it("creates target providers directory", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + // vendor dir does not exist, project dir does not exist + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.readdirSync).mockReturnValue([]); + + syncKeycloakProviders(); + + expect(fs.mkdirSync).toHaveBeenCalledWith( + expect.stringContaining("providers"), + {recursive: true}, + ); + }); + + it("copies .jar files from vendor/providers/ to target dir", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + // vendor dir exists, project providers dir does not + return pathStr.includes("vendor/providers"); + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.includes("vendor/providers")) { + return ["plugin.jar", "another.jar"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(2); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("plugin.jar"), + expect.stringContaining("providers"), + ); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("another.jar"), + expect.stringContaining("providers"), + ); + }); + + it("skips non-JAR files in vendor dir", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + return String(p).includes("vendor/providers"); + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + if (String(p).includes("vendor/providers")) { + return ["readme.txt", "plugin.jar", "config.xml"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("plugin.jar"), + expect.any(String), + ); + }); + + it("silently returns when vendor/providers/ directory does not exist", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockReturnValue(false); + + expect(() => syncKeycloakProviders()).not.toThrow(); + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); + + it("copies project-specific JARs from auth/providers//target/", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + // vendor dir does not exist, but project providers exist + if (pathStr.includes("vendor/providers")) return false; + if (pathStr.endsWith("auth/providers")) return true; + if (pathStr.endsWith("target")) return true; + return false; + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.endsWith("auth/providers")) { + return ["my-provider"] as any; + } + if (pathStr.endsWith("target")) { + return ["my-provider.jar"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).toHaveBeenCalledTimes(1); + expect(fs.copyFileSync).toHaveBeenCalledWith( + expect.stringContaining("my-provider.jar"), + expect.stringContaining("providers"), + ); + }); + + it("skips project provider directories that have no target/ folder", () => { + vi.mocked(getPostkitAuthDir).mockReturnValue("/project/.postkit/auth"); + vi.mocked(fs.existsSync).mockImplementation((p) => { + const pathStr = String(p); + if (pathStr.includes("vendor/providers")) return false; + if (pathStr.endsWith("auth/providers")) return true; + // target does not exist + if (pathStr.endsWith("target")) return false; + return false; + }); + vi.mocked(fs.readdirSync).mockImplementation((p) => { + if (String(p).endsWith("auth/providers")) { + return ["my-provider"] as any; + } + return [] as any; + }); + + syncKeycloakProviders(); + + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/cli/test/modules/stack/utils/stack-config.test.ts b/cli/test/modules/stack/utils/stack-config.test.ts new file mode 100644 index 0000000..324c00f --- /dev/null +++ b/cli/test/modules/stack/utils/stack-config.test.ts @@ -0,0 +1,454 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// Mock fs BEFORE any imports that use it +vi.mock("fs", () => ({ + default: { + existsSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + }, +})); + +vi.mock("../../../../src/common/config", () => ({ + loadPostkitConfig: vi.fn(), + getSecretsFilePath: vi.fn(() => "/project/postkit.secrets.json"), + getPostkitDir: vi.fn(() => "/project/.postkit"), +})); + +import fs from "fs"; +import {loadPostkitConfig, getSecretsFilePath} from "../../../../src/common/config"; +import {getStackConfig, ensureStackSecrets} from "../../../../src/modules/stack/utils/stack-config"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFullStackConfig(overrides: Partial = {}): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcsecret", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + jwk: undefined, + clients: undefined, + keycloakClients: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// getStackConfig() +// --------------------------------------------------------------------------- + +describe("getStackConfig()", () => { + beforeEach(() => { + vi.clearAllMocks(); + // No secrets file by default + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + it("returns config with default port 25432 for postgres when none configured", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.port).toBe(25432); + }); + + it("returns all service defaults when stack config is empty", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.image).toBe("postgres:16-alpine"); + expect(cfg.postgres.enabled).toBe(true); + expect(cfg.postgres.database).toBe("postkit"); + expect(cfg.postgres.user).toBe("postgres"); + expect(cfg.postgres.password).toBe(""); + expect(cfg.keycloak.port).toBe(28080); + expect(cfg.keycloak.adminUser).toBe("admin"); + expect(cfg.keycloak.adminPassword).toBe(""); + expect(cfg.postgrest.port).toBe(3000); + expect(cfg.traefik.httpPort).toBe(80); + expect(cfg.traefik.dashboardPort).toBe(8080); + expect(cfg.network).toBe("postkit-net"); + }); + + it("merges user-supplied postgres port over the default", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {port: 54321}}, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.port).toBe(54321); + }); + + it("reads postgres user and password from merged secrets in config", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: { + postgres: {user: "myuser", password: "mypass"}, + }, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.postgres.user).toBe("myuser"); + expect(cfg.postgres.password).toBe("mypass"); + }); + + it("reads jwks from secrets file when it exists", () => { + const jwks = {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "abc"}]}; + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({stack: {jwks}}) as any, + ); + + const cfg = getStackConfig(); + + expect(cfg.jwks.keys).toHaveLength(1); + expect(cfg.jwks.keys[0]!.kid).toBe("k1"); + }); + + it("returns empty jwks when secrets file does not exist", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + vi.mocked(fs.existsSync).mockReturnValue(false); + + const cfg = getStackConfig(); + + expect(cfg.jwks).toEqual({keys: []}); + }); + + it("throws when postgres port is out of valid range", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {port: 99999}}, + } as any); + + expect(() => getStackConfig()).toThrow(); + }); + + it("throws when postgres pgVersion is below minimum (12)", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {postgres: {pgVersion: 10}}, + } as any); + + expect(() => getStackConfig()).toThrow(); + }); + + it("returns keycloakClients array from config", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({ + stack: {keycloak: {clients: ["app", "mobile"]}}, + } as any); + + const cfg = getStackConfig(); + + expect(cfg.keycloakClients).toEqual(["app", "mobile"]); + }); + + it("returns empty keycloakClients when none configured", () => { + vi.mocked(loadPostkitConfig).mockReturnValue({stack: {}} as any); + + const cfg = getStackConfig(); + + expect(cfg.keycloakClients).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// ensureStackSecrets() +// --------------------------------------------------------------------------- + +describe("ensureStackSecrets()", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSecretsFilePath).mockReturnValue("/project/postkit.secrets.json"); + }); + + it("generates random postgres password when password is empty", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result.postgres.password).toBeTruthy(); + expect(result.postgres.password.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("generates random keycloak adminPassword when adminPassword is empty", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result.keycloak.adminPassword).toBeTruthy(); + expect(result.keycloak.adminPassword.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("preserves existing postgres password and does not overwrite it", () => { + const existingSecrets = { + stack: { + postgres: {user: "postgres", password: "existing-pg-pass"}, + keycloak: {adminUser: "admin", adminPassword: "existing-kc-pass"}, + jwks: {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "akey"}], urlSigningKey: {kty: "oct", kid: "k1", alg: "HS256", k: "akey"}}, + }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingSecrets) as any); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "existing-pg-pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "existing-kc-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + // Should NOT change existing passwords + expect(result.postgres.password).toBe("existing-pg-pass"); + expect(result.keycloak.adminPassword).toBe("existing-kc-pass"); + }); + + it("does not write secrets file when all secrets already exist", () => { + const existingSecrets = { + stack: { + postgres: {user: "postgres", password: "existing-pg-pass"}, + keycloak: {adminUser: "admin", adminPassword: "existing-kc-pass"}, + jwks: {keys: [{kty: "oct", kid: "k1", alg: "HS256", k: "akey"}], urlSigningKey: {kty: "oct", kid: "k1", alg: "HS256", k: "akey"}}, + }, + }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(existingSecrets) as any); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "existing-pg-pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "existing-kc-pass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + ensureStackSecrets(config); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it("writes generated secrets to postkit.secrets.json", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + + let writtenPath = ""; + let writtenContent = ""; + vi.mocked(fs.writeFileSync).mockImplementation((p, c) => { + writtenPath = p as string; + writtenContent = c as string; + }); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + ensureStackSecrets(config); + + expect(writtenPath).toBe("/project/postkit.secrets.json"); + const written = JSON.parse(writtenContent); + expect(written.stack.postgres.password).toBeTruthy(); + expect(written.stack.keycloak.adminPassword).toBeTruthy(); + }); + + it("generates jwks when absent from secrets", () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + stack: { + postgres: {user: "postgres", password: "pass"}, + keycloak: {adminUser: "admin", adminPassword: "kcpass"}, + // no jwks + }, + }) as any, + ); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "pass", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + jwks: {keys: []}, + }); + + const result = ensureStackSecrets(config); + + expect(result.jwks.keys.length).toBeGreaterThan(0); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it("returns the updated StackConfig", () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + + const config = makeFullStackConfig({ + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + }); + + const result = ensureStackSecrets(config); + + expect(result).toBeDefined(); + expect(result.postgres).toBeDefined(); + expect(result.keycloak).toBeDefined(); + }); +}); diff --git a/cli/test/modules/stack/utils/stack-state.test.ts b/cli/test/modules/stack/utils/stack-state.test.ts new file mode 100644 index 0000000..3c26fa2 --- /dev/null +++ b/cli/test/modules/stack/utils/stack-state.test.ts @@ -0,0 +1,242 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; + +// --------------------------------------------------------------------------- +// pg mock — Client is vi.fn() with no default implementation. +// Each test calls vi.mocked(Client).mockImplementation(function() {...}) +// NOTE: Must use regular `function` keyword (not arrow) — Vitest requires +// constructable functions when using `new` with mocked classes. +// --------------------------------------------------------------------------- +vi.mock("pg", () => ({ + Client: vi.fn(), +})); + +// Mock buildPgUrl so we don't need a real DB config +vi.mock("../../../../src/modules/stack/services/db-init", () => ({ + buildPgUrl: vi.fn(function() { return "postgres://postgres:secret@localhost:25432/postkit"; }), +})); + +import {Client} from "pg"; +import {readStackIsInitial, setStackInitialized} from "../../../../src/modules/stack/utils/stack-state"; +import type {StackConfig} from "../../../../src/modules/stack/types/config"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockClient(overrides: { + connectError?: Error; + queryResult?: {rows: {value: string}[]; rowCount: number}; + queryError?: Error; +} = {}) { + return { + connect: overrides.connectError + ? vi.fn().mockRejectedValue(overrides.connectError) + : vi.fn().mockResolvedValue(undefined), + query: overrides.queryError + ? vi.fn().mockRejectedValue(overrides.queryError) + : vi.fn().mockResolvedValue(overrides.queryResult ?? {rows: [], rowCount: 0}), + end: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeMockConfig(): StackConfig { + return { + postgres: { + image: "postgres:16-alpine", + enabled: true, + port: 25432, + user: "postgres", + password: "secret", + database: "postkit", + pgVersion: 16, + volume: "postkit-pgdata", + }, + keycloak: { + image: "quay.io/keycloak/keycloak:26.6", + enabled: true, + port: 28080, + adminUser: "admin", + adminPassword: "kcpass", + realm: "postkit", + clientRealm: "postkit", + volume: "postkit-keycloak-data", + realmTemplate: "", + }, + postgrest: { + image: "postgrest/postgrest:latest", + enabled: true, + port: 3000, + dbSchema: "public", + dbAnonRole: "anon", + }, + traefik: { + image: "traefik:v3.3", + enabled: true, + httpPort: 80, + dashboardPort: 8080, + }, + network: "postkit-net", + jwks: {keys: []}, + keycloakClients: [], + }; +} + +// Helper to mock Client constructor using a regular function (required by Vitest) +function setupClientMock(mockClient: ReturnType) { + vi.mocked(Client).mockImplementation(function() { + return mockClient as any; + } as any); +} + +// --------------------------------------------------------------------------- +// readStackIsInitial() +// --------------------------------------------------------------------------- + +describe("readStackIsInitial()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns true when no row exists (empty result set)", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns false when row has value = 'false'", async () => { + const mockClient = makeMockClient({ + queryResult: {rows: [{value: "false"}], rowCount: 1}, + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(false); + }); + + it("returns true when row has value = 'true'", async () => { + const mockClient = makeMockClient({ + queryResult: {rows: [{value: "true"}], rowCount: 1}, + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns true when DB query throws (table doesn't exist yet)", async () => { + const mockClient = makeMockClient({ + queryError: new Error("relation does not exist"), + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("returns true when connect throws", async () => { + const mockClient = makeMockClient({ + connectError: new Error("connection refused"), + }); + setupClientMock(mockClient); + + const result = await readStackIsInitial(makeMockConfig()); + + expect(result).toBe(true); + }); + + it("closes pg client even on query error", async () => { + const mockClient = makeMockClient({ + queryError: new Error("some query error"), + }); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("closes pg client on success", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("queries the correct table and key", async () => { + const mockClient = makeMockClient({queryResult: {rows: [], rowCount: 0}}); + setupClientMock(mockClient); + + await readStackIsInitial(makeMockConfig()); + + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("postkit.stack_config"), + ["is_initial"], + ); + }); +}); + +// --------------------------------------------------------------------------- +// setStackInitialized() +// --------------------------------------------------------------------------- + +describe("setStackInitialized()", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("executes upsert with value = 'false'", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + const call = mockClient.query.mock.calls[0]!; + const sql = call[0] as string; + const params = call[1] as string[]; + + expect(sql).toContain("INSERT INTO postkit.stack_config"); + expect(sql).toContain("'false'"); + expect(sql.toLowerCase()).toContain("on conflict"); + expect(params).toContain("is_initial"); + }); + + it("closes pg client even when query throws", async () => { + const mockClient = makeMockClient({ + queryError: new Error("insert failed"), + }); + setupClientMock(mockClient); + + await expect(setStackInitialized(makeMockConfig())).rejects.toThrow("insert failed"); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("closes pg client on success", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + expect(mockClient.end).toHaveBeenCalledTimes(1); + }); + + it("connects before querying", async () => { + const mockClient = makeMockClient(); + setupClientMock(mockClient); + + await setStackInitialized(makeMockConfig()); + + const connectOrder = mockClient.connect.mock.invocationCallOrder[0]!; + const queryOrder = mockClient.query.mock.invocationCallOrder[0]!; + + expect(connectOrder).toBeLessThan(queryOrder); + }); +}); From fcfc4ee8971b160422175f7523bfd87ea02386ca Mon Sep 17 00:00:00 2001 From: supunappri99 Date: Mon, 8 Jun 2026 23:52:30 +0530 Subject: [PATCH 23/23] docs: add stack module command documentation and update architecture references --- CLAUDE.md | 122 ++++- cli/docs/architecture.md | 87 +++- cli/docs/e2e-testing.md | 83 ++++ cli/docs/stack.md | 480 ++++++++++++-------- docs/docs/getting-started/quick-start.md | 4 + docs/docs/modules/stack/commands/down.md | 31 ++ docs/docs/modules/stack/commands/keys.md | 31 ++ docs/docs/modules/stack/commands/logs.md | 32 ++ docs/docs/modules/stack/commands/realm.md | 31 ++ docs/docs/modules/stack/commands/restart.md | 23 + docs/docs/modules/stack/commands/status.md | 17 + docs/docs/modules/stack/commands/up.md | 45 ++ docs/docs/modules/stack/overview.mdx | 131 ++++++ docs/sidebars.ts | 25 + 14 files changed, 912 insertions(+), 230 deletions(-) create mode 100644 docs/docs/modules/stack/commands/down.md create mode 100644 docs/docs/modules/stack/commands/keys.md create mode 100644 docs/docs/modules/stack/commands/logs.md create mode 100644 docs/docs/modules/stack/commands/realm.md create mode 100644 docs/docs/modules/stack/commands/restart.md create mode 100644 docs/docs/modules/stack/commands/status.md create mode 100644 docs/docs/modules/stack/commands/up.md create mode 100644 docs/docs/modules/stack/overview.mdx diff --git a/CLAUDE.md b/CLAUDE.md index 757f4cd..8d01b8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,12 +40,18 @@ cli/ │ │ ├── services/ # Core business logic (database, pgschema, dbmate) │ │ ├── utils/ # DB-specific utilities (session, db-config, remotes) │ │ └── types/ # DB module types -│ └── auth/ # Keycloak auth module -│ ├── index.ts # Module registration (registerAuthModule) -│ ├── commands/ # export, import, sync -│ ├── services/ # Keycloak API, Docker importer -│ └── utils/ # Auth-specific config -├── vendor/ # Bundled binaries (pgschema for all platforms) +│ ├── auth/ # Keycloak auth module +│ │ ├── index.ts # Module registration (registerAuthModule) +│ │ ├── commands/ # export, import, sync +│ │ ├── services/ # Keycloak API, Docker importer +│ │ └── utils/ # Auth-specific config +│ └── stack/ # Local backend stack module +│ ├── index.ts # Module registration (registerStackModule) +│ ├── commands/ # up, down, status, logs, restart, keys, realm +│ ├── services/ # compose, db-init, health, keycloak-keys, realm-init, scaffold, sync-providers +│ ├── utils/ # stack-config, stack-state +│ └── types/ # Stack config types +├── vendor/ # Bundled binaries (pgschema for all platforms) + providers/ (Keycloak JARs) ├── dist/ # Build output (generated) ├── package.json └── tsup.config.ts # Build configuration @@ -112,9 +118,12 @@ PostKit files are split between committed (shared with team) and gitignored (use │ ├── session/ # GITIGNORED — temporary in-progress migrations │ ├── committed.json # COMMITTED — migration tracking index (shared) │ └── migrations/ # COMMITTED — committed SQL migrations for deploy (shared) -└── auth/ - ├── raw/ # COMMITTED — auth raw config (shared) - └── realm/ # COMMITTED — auth realm config (shared) +├── auth/ +│ ├── raw/ # COMMITTED — auth raw config (shared) +│ ├── realm/ # COMMITTED — auth realm config (shared) +│ └── providers/ # GITIGNORED — Keycloak JAR providers (copied from vendor + project) +└── stack/ + └── docker-compose.yml # GITIGNORED — generated compose file (ephemeral) ``` **Key paths** (from `modules/db/utils/db-config.ts`): @@ -136,21 +145,86 @@ PostKit files are split between committed (shared with team) and gitignored (use - `resolveApplyTarget(target?)` (`utils/apply-target.ts`) - Resolves `"local"` or `"remote"` apply target; used by infra and seed commands - `readJsonFile(path)` / `writeJsonFile(path, data)` (`utils/json-file.ts`) - Typed JSON helpers used by remotes and committed migration tracking +### Stack Module Architecture + +The `stack` module manages a local backend service stack (Postgres, Keycloak, PostgREST, Traefik) using Docker Compose. + +**Services:** + +| Service | Image | Port | Purpose | +|---------|-------|------|---------| +| `postgres` | `postgres:16-alpine` | 25432 | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | via Traefik | Auth server | +| `postgrest` | `postgrest/postgrest:latest` | via Traefik | REST API | +| `traefik` | `traefik:v3.3` | 80 / 8080 | Reverse proxy + dashboard | + +**`stack up` startup sequence:** +1. Start `postgres` + `traefik` (Phase 1 infrastructure) +2. Wait for health checks on infrastructure services +3. `applyStackDeploy` — creates `postkit` schema, applies `db/infra/`, committed migrations, seeds (hard failure) +4. Start `keycloak` + `postgrest` (Phase 2 — only after DB is initialized) +5. Wait for health checks on all services +6. If `is_initial=true`: import realm template → fetch JWKs → update PostgREST → mark `is_initial=false` + +**`is_initial` flag** — stored in `postkit.stack_config` table in `postkit` schema: +- `true` (default / missing row) → runs realm import + JWKs fetch on next `stack up` +- `false` → skips realm/JWKs on subsequent starts +- Automatically resets when DB volumes are wiped (`stack down --volumes`) +- Manual reset: `postkit stack realm` or `postkit stack keys` + +**Keycloak providers** (`services/sync-providers.ts`): +- Bundled JARs from `vendor/providers/` are copied to `.postkit/auth/providers/` on `postkit init` +- Project-specific JARs from `auth/providers//target/*.jar` are also synced +- The providers directory is mounted into Keycloak at `/opt/keycloak/providers` + +**Realm template + JWT Role Mapper** (`services/realm-init.ts`): +- Default template scaffolded at `.postkit/auth/realm/postkit.json` +- `cleanRealmTemplate()` strips builtin clients, strips IDs/secrets, injects `JWT_ROLE_MAPPER` (`script-primary-role.js`) into every non-builtin client +- Import uses `keycloak-config-cli` via `docker run --network postkit-net` + +**Key paths** (from `modules/stack/utils/stack-config.ts`): +- `getStackDir()` — `.postkit/stack/` +- `getComposeFilePath()` — `.postkit/stack/docker-compose.yml` +- `getProvidersDir()` — `.postkit/auth/providers/` (from `sync-providers.ts`) + +**Key functions:** +- `getStackConfig()` (`utils/stack-config.ts`) — Loads + validates stack config, resolves defaults, reads JWKs/client secrets from secrets file +- `ensureStackSecrets(config)` (`utils/stack-config.ts`) — Auto-generates missing passwords/JWKs and writes to `postkit.secrets.json` +- `writeComposeFile(config, services)` (`services/compose.ts`) — Generates `.postkit/stack/docker-compose.yml` using project `name` as Docker Compose project name +- `applyStackDeploy(config, spinner)` (`services/db-init.ts`) — Creates `postkit` schema, applies infra/migrations/seeds via connection retry +- `readStackIsInitial(config)` / `setStackInitialized(config)` (`utils/stack-state.ts`) — Read/write `is_initial` flag in `postkit.stack_config` +- `syncKeycloakProviders(spinner?)` (`services/sync-providers.ts`) — Copies JARs from vendor + project into `.postkit/auth/providers/` +- `importRealmTemplate(config, spinner?)` (`services/realm-init.ts`) — Cleans realm JSON and imports via `keycloak-config-cli` container +- `cleanRealmTemplate(raw, realmName)` (`services/realm-init.ts`) — Strips builtins, injects JWT Role Mapper + +**`postkit init` scaffold additions:** +- Prompts for project name → generates `_<8hexchars>`, stored as `name` in `postkit.config.json` +- Creates `db/infra/001_roles.sql` (anon, authenticated, service_role, app_user, authenticator roles) +- Creates `db/infra/002_schemas.sql` (public, auth, storage schemas) +- Copies vendor provider JARs to `.postkit/auth/providers/` +- Scaffolds realm template at `.postkit/auth/realm/postkit.json` + ### Configuration System Config is loaded by `loadPostkitConfig()` from `common/config.ts`, which deep-merges two files: | File | Committed | Purpose | |------|-----------|---------| -| `postkit.config.json` | Yes | Non-sensitive project settings (schema paths, flags) | -| `postkit.secrets.json` | No (gitignored) | Credentials + all remote config (URLs, names, defaults) | +| `postkit.config.json` | Yes | Non-sensitive project settings (schema paths, flags, stack service config) | +| `postkit.secrets.json` | No (gitignored) | Credentials + all remote config (URLs, names, defaults) + stack secrets | **`postkit.config.json` (committed):** ```json { + "name": "myapp_a3f2b1c0", "db": { "schemaPath": "db/schema", - "schema": "public" + "schemas": ["public"], + "infraPath": "db/infra" + }, + "auth": { "configCliImage": "adorsys/keycloak-config-cli:latest-26" }, + "stack": { + "keycloak": { "realmTemplate": ".postkit/auth/realm/postkit.json" } } } ``` @@ -164,6 +238,10 @@ Config is loaded by `loadPostkitConfig()` from `common/config.ts`, which deep-me "dev": { "url": "postgres://...", "default": true, "addedAt": "2024-12-31T10:00:00.000Z" }, "staging": { "url": "postgres://..." } } + }, + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } } } ``` @@ -216,6 +294,25 @@ Remotes are managed via utilities in `modules/db/utils/remotes.ts`: | `postkit db seed [--apply]` | Apply seed data | | `postkit db schema add ` | Scaffold schema dirs + update infra + register in config | +## Stack Module Commands Reference + +| Command | Purpose | +|---------|---------| +| `postkit stack up [services...]` | Start full stack (two-phase: infra first, then keycloak+postgrest) | +| `postkit stack up --no-wait` | Start without waiting for health checks | +| `postkit stack up --no-keys` | Start without auto-fetching Keycloak JWKs | +| `postkit stack down` | Stop all services and remove containers | +| `postkit stack down --volumes` | Stop all services and remove containers + volumes | +| `postkit stack status` | Show running service health | +| `postkit stack logs [service]` | Tail logs for all or a specific service | +| `postkit stack logs [service] -f` | Follow log output (default behavior) | +| `postkit stack logs [service] -n ` | Show last N lines (default 100) | +| `postkit stack restart [services...]` | Restart one or more services (validates names) | +| `postkit stack keys` | Fetch Keycloak JWKs + client secrets, update PostgREST | +| `postkit stack keys --restart` | Fetch keys then restart PostgREST | +| `postkit stack keys --clients ` | Fetch keys for specific comma-separated client names | +| `postkit stack realm` | Re-import the Keycloak realm template | + ## Common Patterns ### Command Handler Structure @@ -384,6 +481,7 @@ Skills are invoked via `/` in Claude Code. Agents are sub-processes | `cli/docs/architecture.md` | System architecture, module system, dependency direction | | `cli/docs/db.md` | Database module workflow and commands | | `cli/docs/auth.md` | Auth module workflow and commands | +| `cli/docs/stack.md` | Stack module — services, startup sequence, config, commands | | `cli/docs/e2e-testing.md` | E2E testing guide and infrastructure | ### Skills Registry diff --git a/cli/docs/architecture.md b/cli/docs/architecture.md index 8cfce12..5f9f0db 100644 --- a/cli/docs/architecture.md +++ b/cli/docs/architecture.md @@ -9,16 +9,16 @@ System architecture and design decisions for the PostKit modular CLI toolkit. PostKit is a modular CLI toolkit built with **TypeScript** and **Node.js** that provides developer tools for database migrations and Keycloak auth management. It uses a **plugin module architecture** where each feature is self-contained. ``` -┌─────────────────────────────────────────────────────────┐ -│ postkit (CLI) │ -│ cli/src/index.ts │ -├──────────┬──────────────────────────┬───────────────────┤ -│ init │ db module │ auth module │ -│ command │ (migrations, import) │ (Keycloak sync) │ -├──────────┴──────────────────────────┴───────────────────┤ -│ common layer │ -│ config · logger · shell · types · init-check │ -└─────────────────────────────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────────────┐ +│ postkit (CLI) │ +│ cli/src/index.ts │ +├──────────┬────────────────────┬──────────────────┬────────────────────┤ +│ init │ db module │ auth module │ stack module │ +│ command │ (migrations,import)│ (Keycloak sync) │ (local dev stack) │ +├──────────┴────────────────────┴──────────────────┴────────────────────┤ +│ common layer │ +│ config · logger · shell · types · init-check │ +└───────────────────────────────────────────────────────────────────────┘ ``` --- @@ -94,6 +94,38 @@ Keycloak realm configuration management: `export → clean → import` └──────────┘ └───────┘ └────────┘ ``` +### Stack Module (`postkit stack`) + +**Registration**: `registerStackModule()` in `cli/src/modules/stack/index.ts` +**Docs**: `cli/docs/stack.md` + +Local backend stack management via Docker Compose: `up → (init) → keys → down` + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├────────────────────────────────────────────────────────────────────┤ +│ Phase 1: postgres + traefik │ +│ └─▶ waitForAllServices (health checks) │ +│ Phase 2: applyStackDeploy (infra SQL + migrations + seeds) │ +│ Phase 3: keycloak + postgrest │ +│ └─▶ waitForAllServices │ +│ Phase 4: if is_initial=true: │ +│ └─▶ importRealmTemplate → fetchAndMergeKeys │ +│ └─▶ writeComposeFile → composeUp (postgrest) │ +│ └─▶ setStackInitialized (is_initial=false) │ +└────────────────────────────────────────────────────────────────────┘ +``` + +**Architectural decisions:** + +- **DB-backed initialization state**: `is_initial` is stored in `postkit.stack_config` table (not a file) so it resets automatically when volumes are wiped with `stack down --volumes`. No manual cleanup needed. +- **Two-phase boot**: `keycloak` and `postgrest` depend on schema/migrations being applied first. Starting them before `applyStackDeploy` completes causes startup failures. The stack module enforces this ordering explicitly rather than relying on Docker Compose `depends_on` health checks alone. +- **Provider sync at init time**: Keycloak provider JARs are copied to `.postkit/auth/providers/` during `postkit init` (not at startup) so the mount path exists before the container starts. Two sources: `vendor/providers/` (bundled) and `auth/providers//target/` (project-specific). +- **Realm import via keycloak-config-cli**: Import runs `docker run --network postkit-net adorsys/keycloak-config-cli` against the internal container name (`keycloak:8080`), not the Traefik hostname. This allows realm import to complete without Traefik being the entry point. +- **JWT Role Mapper injection**: `cleanRealmTemplate()` injects the `script-primary-role.js` protocol mapper into every non-builtin client automatically, so every client in the realm gets consistent role claim behavior without manual configuration. +- **Project name scoping**: `postkit.config.json` `name` field is used as the Docker Compose project name, ensuring container names and network names are isolated per project on the same machine. + --- ## Multi-Schema Support @@ -203,14 +235,19 @@ Loaded via `loadPostkitConfig()`, which deep-merges two files: ```json // postkit.config.json (committed — no remotes) { + "name": "myapp_a3f2b1c0", "db": { "infraPath": "db/infra", "schemaPath": "db/schema", "schemas": ["public", "app"] + }, + "auth": { "configCliImage": "adorsys/keycloak-config-cli:latest-26" }, + "stack": { + "keycloak": { "realmTemplate": ".postkit/auth/realm/postkit.json" } } } -// postkit.secrets.json (gitignored — all remote data lives here) +// postkit.secrets.json (gitignored — all credentials live here) { "db": { "localDbUrl": "postgres://user:pass@localhost:5432/myapp_local", @@ -218,14 +255,22 @@ Loaded via `loadPostkitConfig()`, which deep-merges two files: "dev": { "url": "postgres://user:pass@dev-host:5432/myapp", "default": true, "addedAt": "2024-12-31T10:00:00.000Z" }, "staging": { "url": "postgres://user:pass@staging-host:5432/myapp" } } + }, + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } } } ``` +**`name`** — Project identifier used as the Docker Compose project name. Generated as `_<8hexchars>` by `postkit init`. Ensures container and network names are scoped per project. + **`schemas`** — Ordered array of schema names (`["public"]` by default). Array position determines execution order; schemas that other schemas depend on must appear first. Backward compat: `"schemas": ["public"]` with a flat `db/schema/` layout (no `db/schema/public/` subdirectory) continues to work unchanged. **`infraPath`** — Path to the DB-level infra directory (roles, extensions, `CREATE SCHEMA`). Defaults to `"db/infra"`. +**`stack.*`** — Stack service configuration. All service images, ports, volumes, and realm template path. Service credentials (passwords, admin user) live in `postkit.secrets.json` under `stack.*`. + `localDbUrl` can be empty — PostKit will automatically start a Docker container (`postgres:{version}-alpine`) for the session. The container image version is detected from the remote database at runtime via `SHOW server_version_num`. --- @@ -267,11 +312,12 @@ cli/test/ ├── common/ # Unit tests for common utilities ├── modules/ # Unit tests for module services/utils │ ├── db/ -│ └── auth/ +│ ├── auth/ +│ └── stack/ # Stack module unit tests (compose, realm-init, scaffold, sync-providers, db-init, stack-config, stack-state, restart) ├── e2e/ # End-to-end tests -│ ├── smoke/ # Quick tests (no Docker) -│ ├── workflows/ # Full workflow tests -│ └── error-handling/ # Error scenario tests +│ ├── smoke/ # Quick tests (no Docker) — includes stack-commands.test.ts +│ ├── workflows/ # Full workflow tests — includes stack-init-workflow.test.ts +│ └── error-handling/ # Error scenario tests — includes stack-config-errors.test.ts └── helpers/ # Shared test utilities (mock-config, mock-shell, etc.) ``` @@ -292,9 +338,12 @@ PostKit files in `.postkit/` are split between gitignored (ephemeral/user-specif │ ├── session/ # GITIGNORED — temporary in-progress migrations │ ├── committed.json # COMMITTED — migration tracking index (shared) │ └── migrations/ # COMMITTED — committed SQL migrations for deploy (shared) -└── auth/ - ├── raw/ # COMMITTED — auth raw config (shared) - └── realm/ # COMMITTED — auth realm config (shared) +├── auth/ +│ ├── raw/ # COMMITTED — auth raw config (shared) +│ ├── realm/ # COMMITTED — auth realm config (shared) +│ └── providers/ # GITIGNORED — Keycloak JARs (vendor + project), mounted into container +└── stack/ + └── docker-compose.yml # GITIGNORED — generated compose file (ephemeral, regenerated on stack up) ``` `.gitignore` (written by `postkit init`) covers only the ephemeral paths: @@ -302,4 +351,6 @@ PostKit files in `.postkit/` are split between gitignored (ephemeral/user-specif - `.postkit/db/plan_*.sql` - `.postkit/db/schema_*.sql` - `.postkit/db/session/` +- `.postkit/auth/providers/` +- `.postkit/stack/` - `postkit.secrets.json` diff --git a/cli/docs/e2e-testing.md b/cli/docs/e2e-testing.md index 7284eb4..cbfb877 100644 --- a/cli/docs/e2e-testing.md +++ b/cli/docs/e2e-testing.md @@ -423,6 +423,89 @@ test/e2e/ --- +## Stack Module Tests + +The stack module has its own E2E test files. None of these tests require Docker to be running — they test CLI behavior and filesystem scaffolding only. + +### Test Files + +| File | Category | Docker | Description | +|------|----------|--------|-------------| +| `smoke/stack-commands.test.ts` | Smoke | No | `stack --help`, `stack up --help`, `stack restart --help`, init-check enforcement | +| `error-handling/stack-config-errors.test.ts` | Error handling | No | Invalid stack config, missing secrets validation | +| `workflows/stack-init-workflow.test.ts` | Workflow | No | `postkit init` scaffold verification | + +### `stack-commands.test.ts` — Smoke Tests + +Verifies subcommand registration and init-check without starting Docker: + +- `stack --help` lists all subcommands (`up`, `down`, `restart`, `status`, `logs`) +- `stack up --help` shows `--no-wait` and `--no-keys` flags +- `stack restart --help` shows `[services...]` variadic argument +- `stack up` / `stack status` / `stack restart` / `stack down` all fail with `not initialized` or `Config file not found` in an empty (uninitialized) directory + +### `stack-init-workflow.test.ts` — Init Workflow (No Docker) + +Tests that `postkit init --force` produces the correct scaffold for the stack module. All assertions are filesystem-based — no containers are started. + +Pattern: +```typescript +beforeAll(async () => { + rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "postkit-e2e-init-")); + await runCli(["init", "--force"], {cwd: rootDir}); +}); +``` + +Assertions verified: +- `.postkit/auth/providers/` directory exists +- `.postkit/stack/` directory exists +- `db/infra/001_roles.sql` exists and contains `IF NOT EXISTS` pattern +- `db/infra/002_schemas.sql` exists and contains `CREATE SCHEMA IF NOT EXISTS auth/public/storage` +- `postkit.config.json` has `stack.keycloak.realmTemplate` pointing to an existing file +- `postkit.config.json` has `name` field matching `[a-z0-9-]*_[0-9a-f]{8}` pattern +- `postkit.secrets.example.json` exists and has `db` + `auth` top-level keys +- `.gitignore` contains `postkit.secrets.json` +- Second `postkit init --force` succeeds (idempotency) + +### Running Stack Tests + +```bash +# Run all stack smoke tests (no Docker) +npm run test:e2e:file -- test/e2e/smoke/stack-commands.test.ts + +# Run stack init workflow test (no Docker) +npm run test:e2e:file -- test/e2e/workflows/stack-init-workflow.test.ts + +# Run stack config error tests (no Docker) +npm run test:e2e:file -- test/e2e/error-handling/stack-config-errors.test.ts + +# Run all stack E2E tests together +npx vitest run --config vitest.e2e.config.ts test/e2e/smoke/stack-commands.test.ts test/e2e/workflows/stack-init-workflow.test.ts test/e2e/error-handling/stack-config-errors.test.ts +``` + +### Stack Unit Tests + +Stack module unit tests live under `test/modules/stack/` and use Vitest with `vi.mock()`: + +| File | What It Tests | +|------|--------------| +| `services/compose.test.ts` | `generateComposeFile()` output, `getSelectedServices()` dependency resolution | +| `services/realm-init.test.ts` | `cleanRealmTemplate()` — strips builtins, injects JWT Role Mapper | +| `services/scaffold.test.ts` | `scaffoldRealmTemplate()` — creates file, skips if exists | +| `services/sync-providers.test.ts` | `syncKeycloakProviders()` — copies JARs from vendor + project dirs | +| `services/db-init.test.ts` | `applyStackDeploy()` — connection retry, schema init, infra/migrations/seeds | +| `utils/stack-config.test.ts` | `getStackConfig()` — defaults, Zod validation, secrets merging | +| `utils/stack-state.test.ts` | `readStackIsInitial()` / `setStackInitialized()` — DB flag read/write | +| `commands/restart.test.ts` | `restartCommand()` — service name validation, multiple targets | + +Run unit tests: +```bash +npm run test:unit # All unit tests +npx vitest run test/modules/stack/ # Stack unit tests only +``` + +--- + ## CI/CD Integration ```yaml diff --git a/cli/docs/stack.md b/cli/docs/stack.md index d9a5cb4..4643f5c 100644 --- a/cli/docs/stack.md +++ b/cli/docs/stack.md @@ -1,155 +1,235 @@ -# PostKit Stack Module +# 📦 Stack Module (`postkit stack`) -The `stack` module manages a local Docker-based backend environment for development. It spins up **PostgreSQL**, **Keycloak**, and **PostgREST** as Docker containers — wired together on a shared network — using a generated `docker-compose.yml` that is written to `.postkit/stack/`. +A local backend stack manager. Starts and manages Postgres, Keycloak, PostgREST, and Traefik as a Docker Compose project, applies DB migrations on startup, and handles Keycloak realm initialization automatically on the first run. --- -## Prerequisites +## 🗂️ Services Overview -- Docker Desktop installed and **running** -- Docker Compose V2 (included in Docker Desktop by default) +| Service | Image | Port | Purpose | +|---------|-------|------|---------| +| `postgres` | `postgres:16-alpine` | 25432 (host) | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | via Traefik | Auth server (`keycloak.localhost`) | +| `postgrest` | `postgrest/postgrest:latest` | via Traefik | REST API (`api.localhost`) | +| `traefik` | `traefik:v3.3` | 80 (HTTP) / 8080 (dashboard) | Reverse proxy | + +All services share a Docker network named `postkit-net`. The network name is explicit (no Docker Compose project prefix) so external containers like `keycloak-config-cli` can join it by name. + +**Dependency rule:** Selecting `keycloak` or `postgrest` automatically includes `postgres` and `traefik`. --- -## Services +## 🚀 `stack up` — Two-Phase Startup + +`stack up` enforces an ordered startup sequence: the database must be initialized before auth/API services start. -| Service | Default Image | Default Port | Purpose | -|---------|--------------|-------------|---------| -| **postgres** | `postgres:16-alpine` | `25432` | PostgreSQL database | -| **keycloak** | `quay.io/keycloak/keycloak:26.6` | `28080` | Auth / identity provider | -| **postgrest** | `postgrest/postgrest:latest` | `3000` | Auto REST API over Postgres | -| **traefik** | `traefik:v3.3` | `80` (HTTP) / `8080` (dashboard) | Reverse proxy + routing | +``` +┌────────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├────────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1 — Infrastructure │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Start: postgres + traefik │ │ +│ │ Wait: health checks (pg_isready, Traefik API) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 2 — DB Initialization (hard failure stops stack up) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ 1. connectWithRetry → CREATE SCHEMA IF NOT EXISTS postkit │ │ +│ │ + CREATE TABLE IF NOT EXISTS postkit.stack_config │ │ +│ │ 2. Apply db/infra/*.sql (roles, schemas, extensions) │ │ +│ │ 3. Run committed migrations (.postkit/db/migrations/) │ │ +│ │ 4. Apply seeds │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 3 — Application Services │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Start: keycloak + postgrest │ │ +│ │ Wait: health checks (Keycloak /health, PostgREST /) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Phase 4 — Initial Setup (first run only, skipped if initialized) │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ readStackIsInitial → true? │ │ +│ │ ├─▶ importRealmTemplate (keycloak-config-cli container) │ │ +│ │ ├─▶ fetchAndMergeKeys (JWKs + client secrets) │ │ +│ │ ├─▶ writeComposeFile + composeUp(postgrest) [update JWT] │ │ +│ │ └─▶ setStackInitialized (is_initial = 'false' in DB) │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────┘ +``` -**Dependency rule**: Keycloak and PostgREST both depend on Postgres and Traefik. Starting either one automatically includes both. +--- -### Traefik Routing +## 🔁 `is_initial` State Management -Traefik listens on port `80` and routes incoming requests by hostname: +Whether to run realm import and JWKs fetch is controlled by a flag stored in the database itself — not in a file — so it resets automatically when the database volume is wiped. -| URL | Routes to | Service | -|-----|-----------|---------| -| `http://keycloak.localhost` | `:8080` | Keycloak | -| `http://api.localhost` | `:3000` | PostgREST | -| `http://localhost:8080/dashboard/` | — | Traefik dashboard | +| State | Location | Meaning | +|-------|----------|---------| +| Missing row (default) | `postkit.stack_config` table | First run — executes realm import + JWKs on next `stack up` | +| `value = 'false'` | `postkit.stack_config` table | Already initialized — Phase 4 is skipped | -Postgres is TCP and accessed directly on port `25432` — not routed through Traefik. +**Automatic reset:** `postkit stack down --volumes` wipes the Postgres volume, which drops `postkit.stack_config`. The next `stack up` finds no row and runs full initialization again. + +**Manual override:** +- `postkit stack realm` — re-import realm without wiping data +- `postkit stack keys` — re-fetch JWKs without wiping data --- -## Configuration +## 🔑 Keycloak Providers + +Keycloak provider JARs are mounted at `/opt/keycloak/providers` inside the container. PostKit assembles the mount source directory at `postkit init` time from two sources: + +| Source | Path | Notes | +|--------|------|-------| +| Bundled JARs | `vendor/providers/*.jar` (CLI) | Copied on `postkit init` | +| Project-specific JARs | `auth/providers//target/*.jar` | Copied on `postkit init` | + +**Destination:** `.postkit/auth/providers/` — gitignored, rebuilt by `postkit init`. + +If you add or update a project provider, re-run `postkit init` to sync the new JAR, then restart the stack. + +--- + +## 🏰 Realm Template + JWT Role Mapper + +On the first `stack up` (when `is_initial=true`), PostKit imports a Keycloak realm template. + +The template path is configured via `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`). Scaffolded automatically by `postkit init`. + +Before importing, `cleanRealmTemplate()` transforms the raw template JSON: + +| Transform | Detail | +|-----------|--------| +| Set realm name | Sets `realm` to `config.keycloak.realm`, removes top-level `id` | +| Strip builtin clients | Removes `account`, `account-console`, `admin-cli`, `broker`, `realm-management`, `security-admin-console` | +| Strip generated fields | Removes `id`, `secret`, `registrationAccessToken`, `client.secret.creation.time` | +| Strip role IDs | Removes `id` from all realm roles | +| Ensure admin role | Adds `admin` realm role if absent | +| Inject JWT Role Mapper | Adds `script-primary-role.js` protocol mapper to every non-builtin client | + +The **JWT Role Mapper** (`protocolMapper: "script-primary-role.js"`) maps Keycloak realm roles into JWT claims in the format expected by PostgREST for role-based access control. + +Import runs via: +``` +docker run --rm --network postkit-net \ + adorsys/keycloak-config-cli:latest-26 +``` +targeting `http://keycloak:8080` (internal Docker DNS, bypasses Traefik). -Stack config is split across two files: +--- -### `postkit.config.json` (committed to git) +## ⚙️ Configuration -Non-sensitive settings — ports, images, database name, realm: +### `postkit.config.json` (committed) ```json { + "name": "myapp_a3f2b1c0", + "db": { + "schemaPath": "db/schema", + "schemas": ["public"], + "infraPath": "db/infra" + }, + "auth": { + "configCliImage": "adorsys/keycloak-config-cli:latest-26" + }, "stack": { "postgres": { "port": 25432, - "pgVersion": 16, - "database": "myapp", - "image": "postgres:16-alpine", - "volume": "postkit-pgdata" + "database": "postkit", + "pgVersion": 16 }, "keycloak": { - "port": 28080, - "realm": "myrealm", - "image": "quay.io/keycloak/keycloak:26.6", - "volume": "postkit-keycloak-data" + "realm": "postkit", + "realmTemplate": ".postkit/auth/realm/postkit.json", + "clients": ["app"] }, "postgrest": { - "port": 3000, "dbSchema": "public", "dbAnonRole": "anon" }, "traefik": { "httpPort": 80, - "dashboardPort": 8080, - "image": "traefik:v3.3" + "dashboardPort": 8080 }, "network": "postkit-net" } } ``` -All fields are optional — defaults are used for anything omitted. +All `stack.*` fields are optional — defaults are applied for anything omitted. ### `postkit.secrets.json` (gitignored) -Credentials only: +Auto-generated by `postkit stack up` on first run. Missing passwords are generated as random 32-byte hex strings. ```json { "stack": { "postgres": { - "user": "myuser", - "password": "..." + "user": "postgres", + "password": "" }, "keycloak": { "adminUser": "admin", - "adminPassword": "..." + "adminPassword": "" }, - "postgrest": { - "jwtSecret": "..." + "jwks": { + "keys": [{ "kty": "oct", "kid": "storage-url-signing-key", "alg": "HS256", "k": "..." }], + "urlSigningKey": { "kty": "oct", "kid": "storage-url-signing-key", "alg": "HS256", "k": "..." } } } } ``` -**Auto-generation**: If passwords or the JWT secret are missing on first `stack up`, PostKit generates cryptographically random values and writes them into `postkit.secrets.json` automatically. You never need to set them manually. +> JWKs and client secrets are populated here by `postkit stack keys` after being fetched from Keycloak. + +### Config Properties Reference + +| Property | File | Default | Description | +|----------|------|---------|-------------| +| `name` | config | required | Docker Compose project name — scopes containers per project | +| `stack.postgres.port` | config | 25432 | Host port mapped to Postgres container | +| `stack.postgres.database` | config | `postkit` | Database name | +| `stack.postgres.pgVersion` | config | 16 | Postgres major version | +| `stack.postgres.volume` | config | `postkit-pgdata` | Docker volume name for Postgres data | +| `stack.keycloak.realm` | config | `postkit` | Keycloak realm name | +| `stack.keycloak.realmTemplate` | config | `.postkit/auth/realm/postkit.json` | Path to realm template | +| `stack.keycloak.clients` | config | `[]` | Client names to fetch secrets for via `stack keys` | +| `stack.keycloak.volume` | config | `postkit-keycloak-data` | Docker volume name for Keycloak data | +| `stack.postgrest.dbSchema` | config | `public` | PostgREST exposed DB schema | +| `stack.postgrest.dbAnonRole` | config | `anon` | PostgREST anonymous role | +| `stack.traefik.httpPort` | config | 80 | Traefik HTTP entry point (host) | +| `stack.traefik.dashboardPort` | config | 8080 | Traefik dashboard port (host) | +| `stack.network` | config | `postkit-net` | Docker network name | +| `stack.postgres.password` | secrets | auto-generated | Postgres password | +| `stack.keycloak.adminPassword` | secrets | auto-generated | Keycloak admin password | --- -## Commands +## 🚀 Commands ### `postkit stack up [services...]` -Start all services or a specific subset. +Start the full stack or selected services. ```bash -postkit stack up # Start all enabled services -postkit stack up postgres # Postgres only -postkit stack up postgres keycloak # Postgres + Keycloak + Traefik (auto) -postkit stack up traefik # Traefik only -postkit stack up --no-wait # Start without waiting for health checks +postkit stack up # Start all services +postkit stack up postgres traefik # Start only postgres + traefik +postkit stack up postgres keycloak # Includes traefik automatically +postkit stack up --no-wait # Skip health check waiting +postkit stack up --no-keys # Skip auto-fetching JWKs on init ``` -**What happens (step by step):** - -``` -1. Check Docker + Docker Compose V2 are available -2. Load config from postkit.config.json + postkit.secrets.json -3. Auto-generate any missing secrets → write to postkit.secrets.json -4. Resolve which services to start - └─ If keycloak or postgrest selected → add postgres automatically -5. Generate docker-compose.yml → write to .postkit/stack/docker-compose.yml -6. Run: docker compose up -d -7. Wait for health checks (unless --no-wait): - └─ postgres → TCP connection probe on port - └─ keycloak → HTTP GET http://localhost:/ - └─ postgrest → HTTP GET http://localhost:/ -8. Print service summary table with URLs and ports -``` - -**Output after success:** - -``` -✔ Stack is running! - -Service URL Port -────────────────────────────────────────────────────────────────────── -PostgreSQL postgres://myuser:***@localhost:25432/myapp 25432 -Keycloak http://localhost:28080 28080 -PostgREST http://localhost:3000 3000 -Traefik http://localhost:8080/dashboard/ 8080 - -Routing: - http://keycloak.localhost → Keycloak - http://api.localhost → PostgREST -``` +Available service names: `postgres`, `keycloak`, `postgrest`, `traefik` --- @@ -158,181 +238,181 @@ Routing: Stop and remove all stack containers. ```bash -postkit stack down # Stop containers, keep data volumes -postkit stack down --volumes # Stop containers AND delete persistent data +postkit stack down # Stop containers, keep volumes (data preserved) +postkit stack down --volumes # Stop containers AND delete volumes (resets is_initial) ``` -**What happens:** - -``` -1. Check .postkit/stack/docker-compose.yml exists - └─ Error if not found (stack was never started) -2. Run: docker compose down [--volumes] -3. Containers removed; volumes preserved unless --volumes passed -``` - -> **Data safety**: Without `--volumes`, PostgreSQL data and Keycloak data survive in Docker named volumes (`postkit-pgdata`, `postkit-keycloak-data`). Re-running `stack up` picks up where you left off. Use `--volumes` only when you want a clean slate. +> Without `--volumes`, Postgres and Keycloak data survive in Docker named volumes. Use `--volumes` for a clean slate — this also resets the `is_initial` flag so the next `stack up` re-runs realm import and JWKs fetch. --- ### `postkit stack status` -Show the current state of all stack containers. +Show running services, ports, and health status. ```bash -postkit stack status # Human-readable table -postkit stack status --json # Machine-readable JSON output +postkit stack status ``` -**Output:** +--- -``` -PostKit Stack Status +### `postkit stack logs [service] [-f] [-n ]` -Service Container State Health Ports -──────────────────────────────────────────────────────── -postgres postkit-postgres running healthy 25432:5432 -keycloak postkit-keycloak running healthy 28080:8080 -postgrest postkit-postgrest running 3000:3000 -``` +Tail logs for all services or a specific service. -With `--json`, returns the raw `ServiceStatus[]` array: -```json -[ - { - "name": "postkit-postgres", - "service": "postgres", - "state": "running", - "health": "healthy", - "ports": "25432:5432", - "publisherPort": 25432 - } -] +```bash +postkit stack logs # Follow all services (default) +postkit stack logs keycloak # Keycloak logs only +postkit stack logs postgres --no-follow # Print last 100 lines and exit +postkit stack logs postgrest -n 50 # Last 50 lines, then follow ``` -**Error:** Throws if `.postkit/stack/docker-compose.yml` does not exist — run `stack up` first. +**Flags:** + +| Flag | Default | Description | +|------|---------|-------------| +| `-f, --follow` | true | Stream logs continuously | +| `--no-follow` | — | Print last N lines and exit | +| `-n, --tail ` | 100 | Number of lines to show | --- -### `postkit stack logs [service]` +### `postkit stack restart [services...]` -Tail logs from one or all services. Follows output by default (like `docker logs -f`). +Restart one or more services. Service names are validated before restarting. ```bash -postkit stack logs # Follow all services -postkit stack logs postgres # Postgres logs only -postkit stack logs keycloak --no-follow # Print last 100 lines and exit -postkit stack logs postgrest -n 50 # Last 50 lines, then follow +postkit stack restart # Restart all services +postkit stack restart keycloak # Restart keycloak only +postkit stack restart keycloak postgrest # Restart multiple services ``` -**Options:** - -| Flag | Default | Description | -|------|---------|-------------| -| `-f, --follow` | `true` | Stream logs continuously | -| `--no-follow` | — | Print last N lines and exit | -| `-n, --tail ` | `100` | Number of lines to show | - -Runs until you press `Ctrl+C`. Output is piped directly to your terminal (colour, formatting preserved). +Invalid service names produce an error listing valid options (`postgres`, `keycloak`, `postgrest`, `traefik`). --- -### `postkit stack restart [service]` +### `postkit stack keys [--restart] [--clients ]` -Restart one service or all services. Waits for health checks after restart. +Fetch JWKs and client secrets from Keycloak and write them to `postkit.secrets.json`. Optionally restarts PostgREST with the updated JWT configuration. ```bash -postkit stack restart # Restart all services -postkit stack restart keycloak # Restart Keycloak only -postkit stack restart postgres # Restart Postgres only +postkit stack keys # Fetch and write to secrets +postkit stack keys --restart # Fetch + restart PostgREST +postkit stack keys --clients "app,admin" # Fetch keys for specific clients only ``` -**What happens:** +--- -``` -1. Check .postkit/stack/docker-compose.yml exists -2. Run: docker compose restart [service] -3. Wait for health checks on restarted service(s) - └─ Non-fatal if still starting — warns and continues +### `postkit stack realm` + +Re-import the Keycloak realm template without restarting the stack. + +```bash +postkit stack realm ``` +Runs `cleanRealmTemplate()` + `importRealmTemplate()` — the same steps as Phase 4 of `stack up`. Use this after editing the realm template or when Keycloak loses its configuration. + --- -## Full Workflow Example +## 📋 Workflow Guide + +### First Run ```bash -# 1. Start everything for the first time +# 1. Initialize the project (creates infra SQL, realm template, providers) +postkit init + +# 2. Start the full stack +# Phase 1: postgres + traefik start and become healthy +# Phase 2: infra SQL + migrations + seeds applied +# Phase 3: keycloak + postgrest start and become healthy +# Phase 4: realm imported, JWKs fetched, postgrest restarted postkit stack up -# → Secrets auto-generated and saved to postkit.secrets.json -# → All three services start, health checks pass -# 2. Check what's running -postkit stack status +# Stack is running: +# Keycloak: http://keycloak.localhost +# API: http://api.localhost +# DB: postgres://postgres:***@localhost:25432/postkit +# Dashboard: http://localhost:8080/dashboard/ +``` + +### Subsequent Runs + +```bash +# Phase 4 is skipped (is_initial=false in DB) +postkit stack up -# 3. Watch Keycloak logs while configuring a realm -postkit stack logs keycloak +# Check health +postkit stack status -# 4. Restart PostgREST after changing db.dbAnonRole in config -postkit stack restart postgrest +# Tail logs +postkit stack logs -# 5. End of day — stop containers but keep DB data +# Stop (keep data) postkit stack down +``` -# 6. Next day — pick up where you left off -postkit stack up +### After Schema Changes + +Schema changes are applied automatically on the next `stack up` (Phase 2 runs committed migrations every time). If the stack is already running: -# 7. Full reset — delete all data and start fresh +```bash +# Deploy schema changes to the running stack DB +postkit db deploy +``` + +### Full Reset + +```bash +# Wipe all data + volumes, reset is_initial flag postkit stack down --volumes + +# Next up runs full initialization again postkit stack up ``` +### Re-importing the Realm Only + +```bash +# Edit .postkit/auth/realm/postkit.json +# Then re-import without restarting services +postkit stack realm +``` + --- -## Internal File Layout +## 🔧 PostKit Directory Structure ``` .postkit/ +├── auth/ +│ ├── realm/ +│ │ └── postkit.json # COMMITTED — realm template (scaffolded by init) +│ └── providers/ # GITIGNORED — Keycloak JARs (vendor + project) +│ └── *.jar └── stack/ - └── docker-compose.yml ← generated on every `stack up`, never committed + └── docker-compose.yml # GITIGNORED — generated compose file (regenerated on stack up) ``` -The compose file is **regenerated every time** `stack up` runs from the current config. You should never edit it manually — changes will be overwritten. - ---- - -## How Health Checks Work - -PostKit waits up to **120 seconds** for each service (60 attempts × 2 second delay): - -| Service | Check type | What it probes | -|---------|-----------|---------------| -| postgres | TCP | Port reachable (`net.connect`) | -| keycloak | HTTP GET | `http://localhost:/` — any response | -| postgrest | HTTP GET | `http://localhost:/` — any response | -| traefik | HTTP GET | `http://localhost:/dashboard/` — any response | - -All checks run in parallel. If any service does not become healthy in time, a warning is shown but the command does not fail — the stack may still be starting. +The compose file is regenerated every time `stack up` runs from the current config. Never edit it manually — changes will be overwritten. --- -## Defaults Reference - -| Setting | Default | -|---------|---------| -| Postgres image | `postgres:16-alpine` | -| Postgres port | `25432` | -| Postgres database | `postkit` | -| Postgres user | `postgres` | -| Postgres volume | `postkit-pgdata` | -| Keycloak image | `quay.io/keycloak/keycloak:26.6` | -| Keycloak port | `28080` | -| Keycloak realm | `postkit` | -| Keycloak volume | `postkit-keycloak-data` | -| PostgREST image | `postgrest/postgrest:latest` | -| PostgREST port | `3000` | -| PostgREST db schema | `public` | -| PostgREST anon role | `anon` | -| Traefik image | `traefik:v3.3` | -| Traefik HTTP port | `80` | -| Traefik dashboard port | `8080` | -| Docker network | `postkit-net` | +## 🐛 Troubleshooting + +| Issue | Solution | +|-------|----------| +| `Docker not found` | Install Docker Desktop; ensure `docker` is on your PATH | +| `docker compose` not available | Install Docker Compose V2 (bundled with Docker Desktop 4.x+) | +| `Config file not found` / `not initialized` | Run `postkit init` before any stack command | +| `Invalid stack configuration` | Check `stack.*` fields in `postkit.config.json` against the Config Properties table | +| Keycloak `Broken pipe` or connection refused during realm import | Keycloak is still starting. Run `postkit stack logs keycloak` and wait for the startup message, then run `postkit stack realm` | +| PostgREST returns 401 after `stack keys` | JWT secret mismatch — run `postkit stack keys --restart` to sync JWKs and restart PostgREST | +| `keycloak-config-cli import failed` | Check `postkit stack logs keycloak` for startup errors. Verify realm template JSON is valid | +| Stack starts but Keycloak has DB errors | Ensure `db/infra/002_schemas.sql` creates the `auth` schema (`CREATE SCHEMA IF NOT EXISTS auth`) — applied in Phase 2 before Keycloak starts | +| `Unknown service: ""` | Valid names are: `postgres`, `keycloak`, `postgrest`, `traefik` | +| Ports already in use (25432, 80, 8080) | Override ports in `postkit.config.json` under `stack.postgres.port`, `stack.traefik.httpPort`, `stack.traefik.dashboardPort` | +| Provider JARs not loaded by Keycloak | Re-run `postkit init` to copy JARs to `.postkit/auth/providers/`, then `postkit stack down && postkit stack up` | +| `stack up` hangs at health check | Run `postkit stack logs` — Keycloak takes 30–60s on first boot. Health check timeout is 120s | +| `postkit stack down --volumes` does not reset realm | The reset is automatic because the `postkit.stack_config` table lives in the Postgres volume. If Keycloak volume was wiped but not Postgres, run `postkit stack realm` manually | diff --git a/docs/docs/getting-started/quick-start.md b/docs/docs/getting-started/quick-start.md index 8d4f58d..00be065 100644 --- a/docs/docs/getting-started/quick-start.md +++ b/docs/docs/getting-started/quick-start.md @@ -105,9 +105,13 @@ PostKit performs a dry-run first to verify the migration works, then deploys to | `postkit db deploy` | Deploy to remote database | | `postkit db status` | Show session state | | `postkit db abort` | Cancel session and clean up | +| `postkit stack up` | Start local backend stack (Postgres, Keycloak, PostgREST, Traefik) | +| `postkit stack down` | Stop all stack services | +| `postkit stack status` | Show stack service health | ## Next Steps - [DB Module Overview](/docs/modules/db/overview) - Learn about the full migration workflow - [Auth Module Overview](/docs/modules/auth/overview) - Manage Keycloak configurations +- [Stack Module Overview](/docs/modules/stack/overview) - Manage local backend services - [Global Options](/docs/reference/global-options) - See all available CLI options diff --git a/docs/docs/modules/stack/commands/down.md b/docs/docs/modules/stack/commands/down.md new file mode 100644 index 0000000..0e40abc --- /dev/null +++ b/docs/docs/modules/stack/commands/down.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 2 +--- + +# stack down + +Stop and remove all stack containers. + +## Usage + +```bash +postkit stack down # Stop containers, keep volumes +postkit stack down --volumes # Stop containers AND remove volumes +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--volumes` | Remove persistent volumes (Postgres data, Keycloak data) | + +## What It Does + +1. Reads `.postkit/stack/docker-compose.yml` +2. Runs `docker compose down` (with `--volumes` if flag is set) + +## Data Safety + +Without `--volumes`, PostgreSQL and Keycloak data survive in Docker named volumes. Re-running `stack up` resumes where you left off. + +With `--volumes`, all data is deleted and the `is_initial` flag resets automatically (the `postkit.stack_config` table is in the Postgres volume). The next `stack up` runs full initialization — realm import and JWKs fetch. diff --git a/docs/docs/modules/stack/commands/keys.md b/docs/docs/modules/stack/commands/keys.md new file mode 100644 index 0000000..d892469 --- /dev/null +++ b/docs/docs/modules/stack/commands/keys.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 6 +--- + +# stack keys + +Fetch JWKs and client secrets from Keycloak and write them to `postkit.secrets.json`. + +## Usage + +```bash +postkit stack keys # Fetch and write keys +postkit stack keys --restart # Fetch + restart PostgREST +postkit stack keys --clients "app,admin" # Fetch keys for specific clients only +``` + +## Options + +| Option | Description | +|--------|-------------| +| `--restart` | Restart PostgREST after updating secrets with new JWKs | +| `--clients ` | Comma-separated client names to fetch (overrides `stack.keycloak.clients` in config) | + +## What It Does + +1. Fetches public JWKs from Keycloak's JWKS endpoint +2. Fetches client secrets for configured clients +3. Writes the merged result to `postkit.secrets.json` under `stack.jwks` and `stack.clients` +4. If `--restart`: regenerates the compose file with updated JWT config and restarts PostgREST + +This command is run automatically during `stack up` Phase 4 (first run). Use it manually to refresh keys without restarting the whole stack. diff --git a/docs/docs/modules/stack/commands/logs.md b/docs/docs/modules/stack/commands/logs.md new file mode 100644 index 0000000..f2234d1 --- /dev/null +++ b/docs/docs/modules/stack/commands/logs.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 4 +--- + +# stack logs + +Tail logs for all services or a specific service. + +## Usage + +```bash +postkit stack logs # Follow all services +postkit stack logs keycloak # Keycloak logs only +postkit stack logs postgres --no-follow # Print last 100 lines and exit +postkit stack logs postgrest -n 50 # Last 50 lines, then follow +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[service]` | Service name to tail. Omit for all services. | + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| `-f, --follow` | true | Stream logs continuously | +| `--no-follow` | — | Print last N lines and exit | +| `-n, --tail ` | 100 | Number of lines to show | + +Press `Ctrl+C` to stop following. diff --git a/docs/docs/modules/stack/commands/realm.md b/docs/docs/modules/stack/commands/realm.md new file mode 100644 index 0000000..14a526a --- /dev/null +++ b/docs/docs/modules/stack/commands/realm.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 7 +--- + +# stack realm + +Re-import the Keycloak realm template into the running Keycloak instance. + +## Usage + +```bash +postkit stack realm +``` + +## What It Does + +1. Reads the realm template from `stack.keycloak.realmTemplate` (default: `.postkit/auth/realm/postkit.json`) +2. Runs `cleanRealmTemplate()` — strips builtin clients, strips IDs/secrets, injects JWT Role Mapper +3. Imports the cleaned template via `keycloak-config-cli` (`docker run --network postkit-net`) + +Keycloak must be running before this command can succeed. + +## When to Use + +- After editing the realm template manually +- When Keycloak loses its configuration (e.g., after a container restart without a volume) +- To retry a failed Phase 4 initialization without restarting the whole stack + +## JWT Role Mapper + +The import automatically injects `script-primary-role.js` as a protocol mapper into every non-builtin client. This mapper converts Keycloak realm roles into JWT claims compatible with PostgREST role-based access control. diff --git a/docs/docs/modules/stack/commands/restart.md b/docs/docs/modules/stack/commands/restart.md new file mode 100644 index 0000000..ff0b483 --- /dev/null +++ b/docs/docs/modules/stack/commands/restart.md @@ -0,0 +1,23 @@ +--- +sidebar_position: 5 +--- + +# stack restart + +Restart one or more services. + +## Usage + +```bash +postkit stack restart # Restart all services +postkit stack restart keycloak # Restart keycloak only +postkit stack restart keycloak postgrest # Restart multiple services +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[services...]` | Services to restart. Omit for all. Valid: `postgres`, `keycloak`, `postgrest`, `traefik` | + +Service names are validated before restarting. An unknown service name produces an error listing valid options. diff --git a/docs/docs/modules/stack/commands/status.md b/docs/docs/modules/stack/commands/status.md new file mode 100644 index 0000000..9922a76 --- /dev/null +++ b/docs/docs/modules/stack/commands/status.md @@ -0,0 +1,17 @@ +--- +sidebar_position: 3 +--- + +# stack status + +Show running services, ports, and health status. + +## Usage + +```bash +postkit stack status +``` + +## What It Does + +Reads `.postkit/stack/docker-compose.yml` and queries Docker for the current state of each container. Displays a table with service name, container name, state, health, and ports. diff --git a/docs/docs/modules/stack/commands/up.md b/docs/docs/modules/stack/commands/up.md new file mode 100644 index 0000000..52ef2ff --- /dev/null +++ b/docs/docs/modules/stack/commands/up.md @@ -0,0 +1,45 @@ +--- +sidebar_position: 1 +--- + +# stack up + +Start the full stack or selected services. + +## Usage + +```bash +postkit stack up # Start all services +postkit stack up postgres traefik # Start specific services +postkit stack up --no-wait # Skip health check waiting +postkit stack up --no-keys # Skip auto-fetching JWKs on init +``` + +## Arguments + +| Argument | Description | +|----------|-------------| +| `[services...]` | Services to start: `postgres`, `keycloak`, `postgrest`, `traefik`. Omit for all. | + +## Options + +| Option | Description | +|--------|-------------| +| `--no-wait` | Skip waiting for health checks | +| `--no-keys` | Skip auto-fetching Keycloak JWKs during first-run initialization | + +## What It Does + +1. Checks Docker and Docker Compose availability +2. Loads config, auto-generates missing secrets (passwords, JWKs) +3. Generates `.postkit/stack/docker-compose.yml` +4. **Phase 1** — Starts `postgres` + `traefik`, waits for health checks +5. **Phase 2** — Applies `db/infra/` SQL, committed migrations, and seeds +6. **Phase 3** — Starts `keycloak` + `postgrest`, waits for health checks +7. **Phase 4** (first run only) — Imports realm template, fetches JWKs, restarts PostgREST + +Phase 4 is skipped when `is_initial = false` in `postkit.stack_config`. It resets automatically after `stack down --volumes`. + +## Dependency Rule + +Selecting `keycloak` or `postgrest` automatically includes `postgres` and `traefik`. diff --git a/docs/docs/modules/stack/overview.mdx b/docs/docs/modules/stack/overview.mdx new file mode 100644 index 0000000..95cb332 --- /dev/null +++ b/docs/docs/modules/stack/overview.mdx @@ -0,0 +1,131 @@ +--- +sidebar_position: 1 +--- + +# Stack Module + +The `stack` module manages a local backend service stack for development — PostgreSQL, Keycloak, PostgREST, and Traefik — using Docker Compose. It handles DB initialization, Keycloak realm import, and JWK key fetching automatically on the first run. + +## Services + +| Service | Image | URL / Port | Purpose | +|---------|-------|-----------|---------| +| `postgres` | `postgres:16-alpine` | `localhost:25432` | Database | +| `keycloak` | `quay.io/keycloak/keycloak:26.6` | `http://keycloak.localhost` | Auth server | +| `postgrest` | `postgrest/postgrest:latest` | `http://api.localhost` | REST API | +| `traefik` | `traefik:v3.3` | Port 80 / dashboard `localhost:8080` | Reverse proxy | + +## Workflow + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ stack up (two-phase) │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Phase 1 Start postgres + traefik → wait for health │ +│ │ │ +│ Phase 2 Apply DB: infra SQL + migrations + seeds │ +│ │ │ +│ Phase 3 Start keycloak + postgrest → wait for health │ +│ │ │ +│ Phase 4 First run only: import realm → fetch JWKs │ +│ │ │ +│ Mark stack as initialized (is_initial = false in DB) │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Phase 4 only runs when the database has no `is_initial = false` record in `postkit.stack_config`. It resets automatically when volumes are wiped with `stack down --volumes`. + +## Commands + +| Command | Description | +|---------|-------------| +| [`up`](/docs/modules/stack/commands/up) | Start all or selected services | +| [`down`](/docs/modules/stack/commands/down) | Stop services, optionally remove volumes | +| [`status`](/docs/modules/stack/commands/status) | Show service health | +| [`logs`](/docs/modules/stack/commands/logs) | Tail service logs | +| [`restart`](/docs/modules/stack/commands/restart) | Restart one or more services | +| [`keys`](/docs/modules/stack/commands/keys) | Fetch Keycloak JWKs and client secrets | +| [`realm`](/docs/modules/stack/commands/realm) | Re-import the Keycloak realm template | + +## Prerequisites + +- Docker Desktop installed and **running** +- Docker Compose V2 (included with Docker Desktop 4.x+) +- Project initialized with `postkit init` + +## Quick Start + +```bash +# Initialize (creates infra SQL, realm template, provider JARs) +postkit init + +# Start full stack — first run imports realm and fetches JWKs automatically +postkit stack up + +# Check status +postkit stack status + +# Stop (keeps data volumes) +postkit stack down + +# Full reset — wipes all data, runs initialization again on next up +postkit stack down --volumes +``` + +## Configuration + +Stack configuration is split across two files: + +### `postkit.config.json` (committed) + +```json +{ + "name": "myapp_a3f2b1c0", + "stack": { + "postgres": { "port": 25432, "database": "postkit" }, + "keycloak": { + "realm": "postkit", + "realmTemplate": ".postkit/auth/realm/postkit.json", + "clients": ["app"] + }, + "postgrest": { "dbSchema": "public", "dbAnonRole": "anon" }, + "traefik": { "httpPort": 80, "dashboardPort": 8080 } + } +} +``` + +### `postkit.secrets.json` (gitignored) + +Auto-generated on first `stack up`. Missing passwords and JWKs are generated automatically. + +```json +{ + "stack": { + "postgres": { "user": "postgres", "password": "" }, + "keycloak": { "adminUser": "admin", "adminPassword": "" } + } +} +``` + +## `is_initial` State + +The stack tracks whether first-run initialization has completed in the `postkit.stack_config` database table. This means the state resets automatically when you wipe volumes — no manual cleanup needed. + +| How to reset | When to use | +|-------------|-------------| +| `postkit stack down --volumes` | Full reset — wipes all data and re-initializes on next `stack up` | +| `postkit stack realm` | Re-import realm template only | +| `postkit stack keys` | Re-fetch JWKs and client secrets only | + +## Output Structure + +``` +.postkit/ +├── auth/ +│ ├── realm/ +│ │ └── postkit.json # Realm template (committed) +│ └── providers/ # Keycloak JARs (gitignored, rebuilt by init) +└── stack/ + └── docker-compose.yml # Generated compose file (gitignored) +``` diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 1721b43..98c325c 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -113,6 +113,31 @@ const sidebars: SidebarsConfig = { ], }, + { + type: 'category', + label: 'Stack Module', + collapsible: true, + collapsed: false, + items: [ + 'modules/stack/overview', + { + type: 'category', + label: 'Commands', + collapsible: true, + collapsed: false, + items: [ + 'modules/stack/commands/up', + 'modules/stack/commands/down', + 'modules/stack/commands/status', + 'modules/stack/commands/logs', + 'modules/stack/commands/restart', + 'modules/stack/commands/keys', + 'modules/stack/commands/realm', + ], + }, + ], + }, + { type: 'category', label: 'Reference',