import { getBuildContext } from "../shared/buildContext"; import { exclude } from "tsafe/exclude"; import type { CliCommandOptions as CliCommandOptions_common } from "../main"; import { promptKeycloakVersion } from "../shared/promptKeycloakVersion"; import { ACCOUNT_V1_THEME_NAME, CONTAINER_NAME } from "../shared/constants"; import { SemVer } from "../tools/SemVer"; import { assert } from "tsafe/assert"; import * as fs from "fs"; import { join as pathJoin, relative as pathRelative, sep as pathSep, basename as pathBasename } from "path"; import * as child_process from "child_process"; import chalk from "chalk"; import chokidar from "chokidar"; import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce"; import { getThisCodebaseRootDirPath } from "../tools/getThisCodebaseRootDirPath"; import { getAbsoluteAndInOsFormatPath } from "../tools/getAbsoluteAndInOsFormatPath"; import cliSelect from "cli-select"; import * as runExclusive from "run-exclusive"; import { extractArchive } from "../tools/extractArchive"; import { appBuild } from "./appBuild"; import { keycloakifyBuild } from "./keycloakifyBuild"; import { isInside } from "../tools/isInside"; import { existsAsync } from "../tools/fs.existsAsync"; import { rm } from "../tools/fs.rm"; export type CliCommandOptions = CliCommandOptions_common & { port: number; keycloakVersion: string | undefined; realmJsonFilePath: string | undefined; }; export async function command(params: { cliCommandOptions: CliCommandOptions }) { exit_if_docker_not_installed: { let commandOutput: Buffer | undefined = undefined; try { commandOutput = child_process.execSync("docker --version", { stdio: ["ignore", "pipe", "ignore"] }); } catch {} if (commandOutput?.toString("utf8").includes("Docker")) { break exit_if_docker_not_installed; } console.log( [ `${chalk.red("Docker required.")}`, `Install it with Docker Desktop: ${chalk.bold.underline( "https://www.docker.com/products/docker-desktop/" )}`, `(or any other way)` ].join(" ") ); process.exit(1); } exit_if_docker_not_running: { let isDockerRunning: boolean; try { child_process.execSync("docker info", { stdio: "ignore" }); isDockerRunning = true; } catch { isDockerRunning = false; } if (isDockerRunning) { break exit_if_docker_not_running; } console.log( [ `${chalk.red("Docker daemon is not running.")}`, `Please start Docker Desktop and try again.` ].join(" ") ); process.exit(1); } const { cliCommandOptions } = params; const buildContext = getBuildContext({ cliCommandOptions }); const { keycloakVersion } = await (async () => { if (cliCommandOptions.keycloakVersion !== undefined) { return { keycloakVersion: cliCommandOptions.keycloakVersion, keycloakMajorNumber: SemVer.parse(cliCommandOptions.keycloakVersion).major }; } console.log( chalk.cyan("On which version of Keycloak do you want to test your theme?") ); const { keycloakVersion } = await promptKeycloakVersion({ startingFromMajor: 18, excludeMajorVersions: [22], buildContext }); console.log(`→ ${keycloakVersion}`); return { keycloakVersion }; })(); const keycloakMajorVersionNumber = SemVer.parse(keycloakVersion).major; { const { isAppBuildSuccess } = await appBuild({ buildContext }); if (!isAppBuildSuccess) { console.log( chalk.red( `App build failed, exiting. Try building your app (e.g 'npm run build') and see what's wrong.` ) ); process.exit(1); } const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({ buildForKeycloakMajorVersionNumber: keycloakMajorVersionNumber, buildContext }); if (!isKeycloakifyBuildSuccess) { console.log( chalk.red( `Keycloakify build failed, exiting. Try running 'npx keycloakify build' and see what's wrong.` ) ); process.exit(1); } } const jarFilePath = fs .readdirSync(buildContext.keycloakifyBuildDirPath) .filter(fileBasename => fileBasename.endsWith(".jar")) .map(fileBasename => pathJoin(buildContext.keycloakifyBuildDirPath, fileBasename)) .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0]; assert(jarFilePath !== undefined); console.log(`Using ${chalk.bold(pathBasename(jarFilePath))}`); const realmJsonFilePath = await (async () => { if (cliCommandOptions.realmJsonFilePath !== undefined) { if (cliCommandOptions.realmJsonFilePath === "none") { return undefined; } console.log( chalk.green( `Using realm json file: ${cliCommandOptions.realmJsonFilePath}` ) ); return getAbsoluteAndInOsFormatPath({ pathIsh: cliCommandOptions.realmJsonFilePath, cwd: process.cwd() }); } const internalFilePath = await (async () => { const dirPath = pathJoin( getThisCodebaseRootDirPath(), "src", "bin", "start-keycloak" ); const filePath = pathJoin( dirPath, `myrealm-realm-${keycloakMajorVersionNumber}.json` ); if (fs.existsSync(filePath)) { return filePath; } console.log( `${chalk.yellow( `Keycloakify do not have a realm configuration for Keycloak ${keycloakMajorVersionNumber} yet.` )}` ); console.log(chalk.cyan("Select what configuration to use:")); const { value } = await cliSelect({ values: [ ...fs .readdirSync(dirPath) .filter(fileBasename => fileBasename.endsWith(".json")), "none" ] }).catch(() => { process.exit(-1); }); if (value === "none") { return undefined; } return pathJoin(dirPath, value); })(); if (internalFilePath === undefined) { return undefined; } const filePath = pathJoin( buildContext.cacheDirPath, pathBasename(internalFilePath) ); fs.writeFileSync( filePath, Buffer.from( fs .readFileSync(internalFilePath) .toString("utf8") .replace(/keycloakify\-starter/g, buildContext.themeNames[0]) ), "utf8" ); return filePath; })(); async function extractThemeResourcesFromJar() { await extractArchive({ archiveFilePath: jarFilePath, onArchiveFile: async ({ relativeFilePathInArchive, writeFile }) => { if (isInside({ dirPath: "theme", filePath: relativeFilePathInArchive })) { await writeFile({ filePath: pathJoin( buildContext.keycloakifyBuildDirPath, relativeFilePathInArchive ) }); } } }); } { const destDirPath = pathJoin(buildContext.keycloakifyBuildDirPath, "theme"); if (await existsAsync(destDirPath)) { await rm(destDirPath, { recursive: true }); } } await extractThemeResourcesFromJar(); const jarFilePath_cacheDir = pathJoin( buildContext.cacheDirPath, pathBasename(jarFilePath) ); fs.copyFileSync(jarFilePath, jarFilePath_cacheDir); try { child_process.execSync(`docker rm --force ${CONTAINER_NAME}`, { stdio: "ignore" }); } catch {} const spawnArgs = [ "docker", [ "run", ...["-p", `${cliCommandOptions.port}:8080`], ...["--name", CONTAINER_NAME], ...["-e", "KEYCLOAK_ADMIN=admin"], ...["-e", "KEYCLOAK_ADMIN_PASSWORD=admin"], ...(realmJsonFilePath === undefined ? [] : [ "-v", `"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json` ]), ...[ "-v", `"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar` ], ...(keycloakMajorVersionNumber <= 20 ? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"] : []), ...[ ...buildContext.themeNames, ...(fs.existsSync( pathJoin( buildContext.keycloakifyBuildDirPath, "theme", ACCOUNT_V1_THEME_NAME ) ) ? [ACCOUNT_V1_THEME_NAME] : []) ] .map(themeName => ({ localDirPath: pathJoin( buildContext.keycloakifyBuildDirPath, "theme", themeName ), containerDirPath: `/opt/keycloak/themes/${themeName}` })) .map(({ localDirPath, containerDirPath }) => [ "-v", `"${localDirPath}":${containerDirPath}:rw` ]) .flat(), ...buildContext.environmentVariables .map(({ name }) => ({ name, envValue: process.env[name] })) .map(({ name, envValue }) => envValue === undefined ? undefined : { name, envValue } ) .filter(exclude(undefined)) .map(({ name, envValue }) => [ "--env", `${name}='${envValue.replace(/'/g, "'\\''")}'` ]) .flat(), `quay.io/keycloak/keycloak:${keycloakVersion}`, "start-dev", ...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24 ? ["--features=declarative-user-profile"] : []), ...(realmJsonFilePath === undefined ? [] : ["--import-realm"]) ], { cwd: buildContext.keycloakifyBuildDirPath, shell: true } ] as const; const child = child_process.spawn(...spawnArgs); child.stdout.on("data", data => process.stdout.write(data)); child.stderr.on("data", data => process.stderr.write(data)); child.on("exit", process.exit); const srcDirPath = pathJoin(buildContext.projectDirPath, "src"); { const handler = async (data: Buffer) => { if (!data.toString("utf8").includes("Listening on: http://0.0.0.0:8080")) { return; } child.stdout.off("data", handler); await new Promise(resolve => setTimeout(resolve, 1_000)); console.log( [ "", `The ftl files from ${chalk.bold( `.${pathSep}${pathRelative(process.cwd(), pathJoin(buildContext.keycloakifyBuildDirPath, "theme"))}` )} are mounted in the Keycloak container.`, "", `Keycloak Admin console: ${chalk.cyan.bold( `http://localhost:${cliCommandOptions.port}` )}`, `- user: ${chalk.cyan.bold("admin")}`, `- password: ${chalk.cyan.bold("admin")}`, "", "", `${chalk.green("Your theme is accessible at:")}`, `${chalk.green("➜")} ${chalk.cyan.bold( `https://my-theme.keycloakify.dev${cliCommandOptions.port === 8080 ? "" : `?port=${cliCommandOptions.port}`}` )}`, "", "You can login with the following credentials:", `- username: ${chalk.cyan.bold("testuser")}`, `- password: ${chalk.cyan.bold("password123")}`, "", `Watching for changes in ${chalk.bold( `.${pathSep}${pathRelative(process.cwd(), buildContext.projectDirPath)}` )}` ].join("\n") ); }; child.stdout.on("data", handler); } { const runFullBuild = runExclusive.build(async () => { console.log(chalk.cyan("Detected changes in the theme. Rebuilding ...")); const { isAppBuildSuccess } = await appBuild({ buildContext }); if (!isAppBuildSuccess) { return; } const { isKeycloakifyBuildSuccess } = await keycloakifyBuild({ buildForKeycloakMajorVersionNumber: keycloakMajorVersionNumber, buildContext }); if (!isKeycloakifyBuildSuccess) { return; } await extractThemeResourcesFromJar(); console.log(chalk.green("Theme rebuilt and updated in Keycloak.")); }); const { waitForDebounce } = waitForDebounceFactory({ delay: 400 }); chokidar .watch( [ srcDirPath, buildContext.publicDirPath, pathJoin(buildContext.projectDirPath, "package.json"), pathJoin(buildContext.projectDirPath, "vite.config.ts"), pathJoin(buildContext.projectDirPath, "vite.config.js"), pathJoin(buildContext.projectDirPath, "index.html"), pathJoin(getThisCodebaseRootDirPath(), "src") ], { ignoreInitial: true } ) .on("all", async (...[, filePath]) => { console.log(`Detected changes in ${filePath}`); await waitForDebounce(); runFullBuild(); }); } }