From 13c21e89107390895b3885b3107f39c88f7b0dea Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 22 Dec 2024 17:09:15 +0100 Subject: [PATCH] Implement initialize-admin-theme command --- .../initializeAccountTheme_singlePage.ts | 4 +- src/bin/initialize-admin-theme.ts | 143 ++++++++++++++++++ src/bin/main.ts | 16 +- .../installUiModulesPeerDependencies.ts | 2 +- .../addPostinstallScriptIfNotPresent.ts | 65 ++++++++ src/bin/shared/customHandler.ts | 1 + src/bin/tools/npmInstall.ts | 55 +++++-- 7 files changed, 274 insertions(+), 12 deletions(-) create mode 100644 src/bin/initialize-admin-theme.ts create mode 100644 src/bin/shared/addPostinstallScriptIfNotPresent.ts diff --git a/src/bin/initialize-account-theme/initializeAccountTheme_singlePage.ts b/src/bin/initialize-account-theme/initializeAccountTheme_singlePage.ts index ae6ae190..0c40d01f 100644 --- a/src/bin/initialize-account-theme/initializeAccountTheme_singlePage.ts +++ b/src/bin/initialize-account-theme/initializeAccountTheme_singlePage.ts @@ -119,7 +119,9 @@ export async function initializeAccountTheme_singlePage(params: { JSON.stringify(parsedPackageJson, undefined, 4) ); - npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) }); + await npmInstall({ + packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) + }); copyBoilerplate({ accountThemeType: "Single-Page", diff --git a/src/bin/initialize-admin-theme.ts b/src/bin/initialize-admin-theme.ts new file mode 100644 index 00000000..0fb73bea --- /dev/null +++ b/src/bin/initialize-admin-theme.ts @@ -0,0 +1,143 @@ +import { dirname as pathDirname, join as pathJoin, relative as pathRelative } from "path"; +import type { BuildContext } from "./shared/buildContext"; +import * as fs from "fs"; +import { maybeDelegateCommandToCustomHandler } from "./shared/customHandler_delegate"; +import { assert, is, type Equals } from "tsafe/assert"; +import { id } from "tsafe/id"; +import { addPostinstallScriptIfNotPresent } from "./shared/addPostinstallScriptIfNotPresent"; +import { getIsPrettierAvailable, runPrettier } from "./tools/runPrettier"; +import { npmInstall } from "./tools/npmInstall"; +import * as child_process from "child_process"; +import { z } from "zod"; +import chalk from "chalk"; + +export async function command(params: { buildContext: BuildContext }) { + const { buildContext } = params; + + const { hasBeenHandled } = maybeDelegateCommandToCustomHandler({ + commandName: "initialize-admin-theme", + buildContext + }); + + if (hasBeenHandled) { + return; + } + + { + const adminThemeSrcDirPath = pathJoin(buildContext.themeSrcDirPath, "admin"); + + if ( + fs.existsSync(adminThemeSrcDirPath) && + fs.readdirSync(adminThemeSrcDirPath).length > 0 + ) { + console.warn( + chalk.red( + `There is already a ${pathRelative( + process.cwd(), + adminThemeSrcDirPath + )} directory in your project. Aborting.` + ) + ); + + process.exit(-1); + } + } + + const parsedPackageJson = (() => { + type ParsedPackageJson = { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; + }; + + const zParsedPackageJson = (() => { + type TargetType = ParsedPackageJson; + + const zTargetType = z.object({ + scripts: z.record(z.union([z.string(), z.undefined()])).optional(), + dependencies: z.record(z.union([z.string(), z.undefined()])).optional(), + devDependencies: z.record(z.union([z.string(), z.undefined()])).optional() + }); + + assert, TargetType>>; + + return id>(zTargetType); + })(); + const parsedPackageJson = JSON.parse( + fs.readFileSync(buildContext.packageJsonFilePath).toString("utf8") + ); + + zParsedPackageJson.parse(parsedPackageJson); + + assert(is(parsedPackageJson)); + + return parsedPackageJson; + })(); + + addPostinstallScriptIfNotPresent({ + parsedPackageJson, + buildContext + }); + + const uiSharedMajor = (() => { + const dependencies = { + ...parsedPackageJson.devDependencies, + ...parsedPackageJson.dependencies + }; + + const version = dependencies["@keycloakify/keycloak-ui-shared"]; + + if (version === undefined) { + return undefined; + } + + const match = version.match(/^[^~]?(\d+)\./); + + if (match === null) { + return undefined; + } + + return match[1]; + })(); + + const moduleName = "@keycloakify/keycloak-admin-ui"; + + const version = ( + JSON.parse( + child_process + .execSync(`npm show ${moduleName} versions --json`) + .toString("utf8") + .trim() + ) as string[] + ).find(version => + uiSharedMajor === undefined ? true : version.startsWith(`${uiSharedMajor}.`) + ); + + assert(version !== undefined); + + (parsedPackageJson.dependencies ??= {})[moduleName] = `~${version}`; + + if (parsedPackageJson.devDependencies !== undefined) { + delete parsedPackageJson.devDependencies[moduleName]; + } + + { + let sourceCode = JSON.stringify(parsedPackageJson, undefined, 2); + + if (await getIsPrettierAvailable()) { + sourceCode = await runPrettier({ + sourceCode, + filePath: buildContext.packageJsonFilePath + }); + } + + fs.writeFileSync( + buildContext.packageJsonFilePath, + Buffer.from(sourceCode, "utf8") + ); + } + + await npmInstall({ + packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) + }); +} diff --git a/src/bin/main.ts b/src/bin/main.ts index 14deed2c..d737c692 100644 --- a/src/bin/main.ts +++ b/src/bin/main.ts @@ -191,7 +191,7 @@ program program .command({ name: "initialize-account-theme", - description: "Initialize the account theme." + description: "Initialize an Account Single-Page or Multi-Page custom Account UI." }) .task({ skip, @@ -202,6 +202,20 @@ program } }); +program + .command({ + name: "initialize-admin-theme", + description: "Initialize an Admin Console custom UI." + }) + .task({ + skip, + handler: async ({ projectDirPath }) => { + const { command } = await import("./initialize-admin-theme"); + + await command({ buildContext: getBuildContext({ projectDirPath }) }); + } + }); + program .command({ name: "copy-keycloak-resources-to-public", diff --git a/src/bin/postinstall/installUiModulesPeerDependencies.ts b/src/bin/postinstall/installUiModulesPeerDependencies.ts index 17f61b0d..416e0fd5 100644 --- a/src/bin/postinstall/installUiModulesPeerDependencies.ts +++ b/src/bin/postinstall/installUiModulesPeerDependencies.ts @@ -149,7 +149,7 @@ export async function installUiModulesPeerDependencies(params: { await fsPr.writeFile(buildContext.packageJsonFilePath, packageJsonContentStr); - npmInstall({ + await npmInstall({ packageJsonDirPath: pathDirname(buildContext.packageJsonFilePath) }); diff --git a/src/bin/shared/addPostinstallScriptIfNotPresent.ts b/src/bin/shared/addPostinstallScriptIfNotPresent.ts new file mode 100644 index 00000000..36dc3c5f --- /dev/null +++ b/src/bin/shared/addPostinstallScriptIfNotPresent.ts @@ -0,0 +1,65 @@ +import { dirname as pathDirname, relative as pathRelative, sep as pathSep } from "path"; +import { assert } from "tsafe/assert"; +import type { BuildContext } from "./buildContext"; + +export type BuildContextLike = { + projectDirPath: string; + packageJsonFilePath: string; +}; + +assert(); + +export function addPostinstallScriptIfNotPresent(params: { + parsedPackageJson: { scripts?: Record }; + buildContext: BuildContextLike; +}) { + const { parsedPackageJson, buildContext } = params; + + const scripts = (parsedPackageJson.scripts ??= {}); + + const cmd_base = "keycloakify postinstall"; + + const projectCliOptionValue = (() => { + const packageJsonDirPath = pathDirname(buildContext.packageJsonFilePath); + + const relativePath = pathRelative( + packageJsonDirPath, + buildContext.projectDirPath + ); + + if (relativePath === "") { + return undefined; + } + + return relativePath.split(pathSep).join("/"); + })(); + + const generateCmd = (params: { cmd_preexisting: string | undefined }) => { + const { cmd_preexisting } = params; + + let cmd = cmd_preexisting === undefined ? "" : `${cmd_preexisting} && `; + + cmd += cmd_base; + + if (projectCliOptionValue !== undefined) { + cmd += ` -p ${projectCliOptionValue}`; + } + + return cmd; + }; + + for (const scriptName of ["postinstall", "prepare"]) { + const cmd_preexisting = scripts[scriptName]; + + if (cmd_preexisting === undefined) { + continue; + } + + if (cmd_preexisting.includes(cmd_base)) { + scripts[scriptName] = generateCmd({ cmd_preexisting }); + return; + } + } + + scripts["postinstall"] = generateCmd({ cmd_preexisting: scripts["postinstall"] }); +} diff --git a/src/bin/shared/customHandler.ts b/src/bin/shared/customHandler.ts index 9e2cafa5..ce02ea5c 100644 --- a/src/bin/shared/customHandler.ts +++ b/src/bin/shared/customHandler.ts @@ -12,6 +12,7 @@ export type CommandName = | "add-story" | "initialize-account-theme" | "initialize-admin-theme" + | "initialize-admin-theme" | "initialize-email-theme" | "copy-keycloak-resources-to-public"; diff --git a/src/bin/tools/npmInstall.ts b/src/bin/tools/npmInstall.ts index 4d917bec..63c15468 100644 --- a/src/bin/tools/npmInstall.ts +++ b/src/bin/tools/npmInstall.ts @@ -9,8 +9,9 @@ import { objectKeys } from "tsafe/objectKeys"; import { getAbsoluteAndInOsFormatPath } from "./getAbsoluteAndInOsFormatPath"; import { exclude } from "tsafe/exclude"; import { rmSync } from "./fs.rmSync"; +import { Deferred } from "evt/tools/Deferred"; -export function npmInstall(params: { packageJsonDirPath: string }) { +export async function npmInstall(params: { packageJsonDirPath: string }) { const { packageJsonDirPath } = params; const packageManagerBinName = (() => { @@ -68,7 +69,7 @@ export function npmInstall(params: { packageJsonDirPath: string }) { console.log(chalk.green("Installing in a way that won't break the links...")); - installWithoutBreakingLinks({ + await installWithoutBreakingLinks({ packageJsonDirPath, garronejLinkInfos }); @@ -77,9 +78,9 @@ export function npmInstall(params: { packageJsonDirPath: string }) { } try { - child_process.execSync(`${packageManagerBinName} install`, { - cwd: packageJsonDirPath, - stdio: "inherit" + await runPackageManagerInstall({ + packageManagerBinName, + cwd: packageJsonDirPath }); } catch { console.log( @@ -90,6 +91,42 @@ export function npmInstall(params: { packageJsonDirPath: string }) { } } +async function runPackageManagerInstall(params: { + packageManagerBinName: string; + cwd: string; +}) { + const { packageManagerBinName, cwd } = params; + + const dCompleted = new Deferred(); + + const child = child_process.spawn(packageManagerBinName, ["install"], { + cwd, + env: process.env, + shell: true + }); + + child.stdout.on("data", data => { + if (data.toString("utf8").includes("has unmet peer dependency")) { + return; + } + + process.stdout.write(data); + }); + + child.stderr.on("data", data => process.stderr.write(data)); + + child.on("exit", code => { + if (code !== 0) { + dCompleted.reject(new Error(`Failed with code ${code}`)); + return; + } + + dCompleted.resolve(); + }); + + await dCompleted.pr; +} + function getGarronejLinkInfos(params: { packageJsonDirPath: string; }): { linkedModuleNames: string[]; yarnHomeDirPath: string } | undefined { @@ -180,7 +217,7 @@ function getGarronejLinkInfos(params: { return { linkedModuleNames, yarnHomeDirPath }; } -function installWithoutBreakingLinks(params: { +async function installWithoutBreakingLinks(params: { packageJsonDirPath: string; garronejLinkInfos: Exclude, undefined>; }) { @@ -261,9 +298,9 @@ function installWithoutBreakingLinks(params: { pathJoin(tmpProjectDirPath, YARN_LOCK) ); - child_process.execSync(`yarn install`, { - cwd: tmpProjectDirPath, - stdio: "inherit" + await runPackageManagerInstall({ + packageManagerBinName: "yarn", + cwd: tmpProjectDirPath }); // NOTE: Moving the modules from the tmp project to the actual project