From 839ba6a964409101aa169720ba801e2d7f958e13 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sun, 11 Feb 2024 18:28:58 +0100 Subject: [PATCH] Improve monorepo support --- src/bin/download-builtin-keycloak-theme.ts | 6 +- src/bin/downloadAndUnzip.ts | 199 ++++++++++++ .../keycloakify/buildOptions/buildOptions.ts | 7 +- .../buildOptions/getCacheDirPath.ts | 6 +- .../getNpmWorkspaceRootDirPath.ts | 27 ++ .../generateTheme/bringInAccountV1.ts | 1 + .../downloadKeycloakStaticResources.ts | 1 + .../generateTheme/generateTheme.ts | 1 + src/bin/tools/downloadAndUnzip.ts | 301 ------------------ src/bin/tools/fetchProxyOptions.ts | 73 +++++ src/bin/tools/fs.existsAsync.ts | 11 + 11 files changed, 326 insertions(+), 307 deletions(-) create mode 100644 src/bin/downloadAndUnzip.ts create mode 100644 src/bin/keycloakify/buildOptions/getNpmWorkspaceRootDirPath.ts delete mode 100644 src/bin/tools/downloadAndUnzip.ts create mode 100644 src/bin/tools/fetchProxyOptions.ts create mode 100644 src/bin/tools/fs.existsAsync.ts diff --git a/src/bin/download-builtin-keycloak-theme.ts b/src/bin/download-builtin-keycloak-theme.ts index a0ed7693..3986132f 100644 --- a/src/bin/download-builtin-keycloak-theme.ts +++ b/src/bin/download-builtin-keycloak-theme.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { join as pathJoin } from "path"; -import { downloadAndUnzip } from "./tools/downloadAndUnzip"; +import { downloadAndUnzip } from "./downloadAndUnzip"; import { promptKeycloakVersion } from "./promptKeycloakVersion"; import { getLogger } from "./tools/logger"; import { readBuildOptions, type BuildOptions } from "./keycloakify/buildOptions"; @@ -13,6 +13,7 @@ import { transformCodebase } from "./tools/transformCodebase"; export type BuildOptionsLike = { cacheDirPath: string; + npmWorkspaceRootDirPath: string; }; assert(); @@ -21,11 +22,10 @@ export async function downloadBuiltinKeycloakTheme(params: { keycloakVersion: st const { keycloakVersion, destDirPath, buildOptions } = params; await downloadAndUnzip({ - "doUseCache": true, - "cacheDirPath": buildOptions.cacheDirPath, destDirPath, "url": `https://github.com/keycloak/keycloak/archive/refs/tags/${keycloakVersion}.zip`, "specificDirsToExtract": ["", "-community"].map(ext => `keycloak-${keycloakVersion}/themes/src/main/resources${ext}/theme`), + buildOptions, "preCacheTransform": { "actionCacheId": "npm install and build", "action": async ({ destDirPath }) => { diff --git a/src/bin/downloadAndUnzip.ts b/src/bin/downloadAndUnzip.ts new file mode 100644 index 00000000..0a738bbf --- /dev/null +++ b/src/bin/downloadAndUnzip.ts @@ -0,0 +1,199 @@ +import { createHash } from "crypto"; +import { mkdir, writeFile, unlink } from "fs/promises"; +import fetch from "make-fetch-happen"; +import { dirname as pathDirname, join as pathJoin, basename as pathBasename } from "path"; +import { assert } from "tsafe/assert"; +import { transformCodebase } from "./tools/transformCodebase"; +import { unzip, zip } from "./tools/unzip"; +import { rm } from "./tools/fs.rm"; +import * as child_process from "child_process"; +import { existsAsync } from "./tools/fs.existsAsync"; +import type { BuildOptions } from "./keycloakify/buildOptions"; +import { getProxyFetchOptions } from "./tools/fetchProxyOptions"; + +export type BuildOptionsLike = { + cacheDirPath: string; + npmWorkspaceRootDirPath: string; +}; + +assert(); + +export async function downloadAndUnzip(params: { + url: string; + destDirPath: string; + specificDirsToExtract?: string[]; + preCacheTransform?: { + actionCacheId: string; + action: (params: { destDirPath: string }) => Promise; + }; + buildOptions: BuildOptionsLike; +}) { + const { url, destDirPath, specificDirsToExtract, preCacheTransform, buildOptions } = params; + + const zipFileBasename = generateFileNameFromURL({ + url, + "preCacheTransform": + preCacheTransform === undefined + ? undefined + : { + "actionCacheId": preCacheTransform.actionCacheId, + "actionFootprint": preCacheTransform.action.toString() + } + }); + + const zipFilePath = pathJoin(buildOptions.cacheDirPath, `${zipFileBasename}.zip`); + const extractDirPath = pathJoin(buildOptions.cacheDirPath, `tmp_unzip_${zipFileBasename}`); + + download_zip_and_transform: { + if (await existsAsync(zipFilePath)) { + break download_zip_and_transform; + } + + const { response, isFromRemoteCache } = await (async () => { + const proxyFetchOptions = await getProxyFetchOptions({ + "npmWorkspaceRootDirPath": buildOptions.npmWorkspaceRootDirPath + }); + + const response = await fetch( + `https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/${pathBasename(zipFilePath)}`, + proxyFetchOptions + ); + + if (response.status === 200) { + return { + response, + "isFromRemoteCache": true + }; + } + + return { + "response": await fetch(url, proxyFetchOptions), + "isFromRemoteCache": false + }; + })(); + + await mkdir(pathDirname(zipFilePath), { "recursive": true }); + + /** + * The correct way to fix this is to upgrade node-fetch beyond 3.2.5 + * (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.) + * Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and + * does not support node-fetch 3.x. So we stick around with this band-aid until + * octokit upgrades. + */ + response.body?.setMaxListeners(Number.MAX_VALUE); + assert(typeof response.body !== "undefined" && response.body != null); + + await writeFile(zipFilePath, response.body); + + if (isFromRemoteCache) { + break download_zip_and_transform; + } + + if (specificDirsToExtract === undefined && preCacheTransform === undefined) { + break download_zip_and_transform; + } + + await unzip(zipFilePath, extractDirPath, specificDirsToExtract); + + try { + await preCacheTransform?.action({ + "destDirPath": extractDirPath + }); + } catch (error) { + await Promise.all([rm(extractDirPath, { "recursive": true }), unlink(zipFilePath)]); + + throw error; + } + + await unlink(zipFilePath); + + await zip(extractDirPath, zipFilePath); + + await rm(extractDirPath, { "recursive": true }); + + upload_to_remot_cache_if_admin: { + const githubToken = process.env["KEYCLOAKIFY_ADMIN_GITHUB_PERSONAL_ACCESS_TOKEN"]; + + if (githubToken === undefined) { + break upload_to_remot_cache_if_admin; + } + + console.log("uploading to remote cache"); + + try { + child_process.execSync(`which putasset`); + } catch { + child_process.execSync(`npm install -g putasset`); + } + + try { + child_process.execFileSync("putasset", [ + "--owner", + "keycloakify", + "--repo", + "keycloakify", + "--tag", + "v0.0.1", + "--filename", + zipFilePath, + "--token", + githubToken + ]); + } catch { + console.log("upload failed, asset probably already exists in remote cache"); + } + } + } + + await unzip(zipFilePath, extractDirPath); + + transformCodebase({ + "srcDirPath": extractDirPath, + "destDirPath": destDirPath + }); + + await rm(extractDirPath, { "recursive": true }); +} + +function generateFileNameFromURL(params: { + url: string; + preCacheTransform: + | { + actionCacheId: string; + actionFootprint: string; + } + | undefined; +}): string { + const { preCacheTransform } = params; + + // Parse the URL + const url = new URL(params.url); + + // Extract pathname and remove leading slashes + let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_"); + + // Optionally, add query parameters replacing special characters + if (url.search) { + fileName += url.search.replace(/[&=?]/g, "-"); + } + + // Replace any characters that are not valid in filenames + fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, ""); + + // Trim or pad the fileName to a specific length + fileName = fileName.substring(0, 50); + + add_pre_cache_transform: { + if (preCacheTransform === undefined) { + break add_pre_cache_transform; + } + + // Sanitize actionCacheId the same way as other components + const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_"); + + fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`; + } + + return fileName; +} diff --git a/src/bin/keycloakify/buildOptions/buildOptions.ts b/src/bin/keycloakify/buildOptions/buildOptions.ts index 5b5f36d9..a9efb540 100644 --- a/src/bin/keycloakify/buildOptions/buildOptions.ts +++ b/src/bin/keycloakify/buildOptions/buildOptions.ts @@ -7,6 +7,7 @@ import { readResolvedViteConfig } from "./resolvedViteConfig"; import * as fs from "fs"; import { getCacheDirPath } from "./getCacheDirPath"; import { getReactAppRootDirPath } from "./getReactAppRootDirPath"; +import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath"; /** Consolidated build option gathered form CLI arguments and config in package.json */ export type BuildOptions = { @@ -30,6 +31,7 @@ export type BuildOptions = { urlPathname: string | undefined; assetsDirPath: string; doBuildRetrocompatAccountTheme: boolean; + npmWorkspaceRootDirPath: string; }; export function readBuildOptions(params: { processArgv: string[] }): BuildOptions { @@ -85,6 +87,8 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption const argv = parseArgv(processArgv); + const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath }); + return { "bundler": resolvedViteConfig !== undefined ? "vite" : "webpack", "isSilent": typeof argv["silent"] === "boolean" ? argv["silent"] : false, @@ -175,6 +179,7 @@ export function readBuildOptions(params: { processArgv: string[] }): BuildOption return pathJoin(reactAppBuildDirPath, resolvedViteConfig.assetsDir); })(), - "doBuildRetrocompatAccountTheme": parsedPackageJson.keycloakify?.doBuildRetrocompatAccountTheme ?? true + "doBuildRetrocompatAccountTheme": parsedPackageJson.keycloakify?.doBuildRetrocompatAccountTheme ?? true, + npmWorkspaceRootDirPath }; } diff --git a/src/bin/keycloakify/buildOptions/getCacheDirPath.ts b/src/bin/keycloakify/buildOptions/getCacheDirPath.ts index 4e553155..9089e09a 100644 --- a/src/bin/keycloakify/buildOptions/getCacheDirPath.ts +++ b/src/bin/keycloakify/buildOptions/getCacheDirPath.ts @@ -1,9 +1,12 @@ import { join as pathJoin } from "path"; import { getAbsoluteAndInOsFormatPath } from "../../tools/getAbsoluteAndInOsFormatPath"; +import { getNpmWorkspaceRootDirPath } from "./getNpmWorkspaceRootDirPath"; export function getCacheDirPath(params: { reactAppRootDirPath: string }) { const { reactAppRootDirPath } = params; + const { npmWorkspaceRootDirPath } = getNpmWorkspaceRootDirPath({ reactAppRootDirPath }); + const cacheDirPath = pathJoin( (() => { if (process.env.XDG_CACHE_HOME !== undefined) { @@ -13,8 +16,7 @@ export function getCacheDirPath(params: { reactAppRootDirPath: string }) { }); } - // TODO: Recursively look up - return pathJoin(reactAppRootDirPath, "node_modules", ".cache"); + return pathJoin(npmWorkspaceRootDirPath, "node_modules", ".cache"); })(), "keycloakify" ); diff --git a/src/bin/keycloakify/buildOptions/getNpmWorkspaceRootDirPath.ts b/src/bin/keycloakify/buildOptions/getNpmWorkspaceRootDirPath.ts new file mode 100644 index 00000000..502ced5c --- /dev/null +++ b/src/bin/keycloakify/buildOptions/getNpmWorkspaceRootDirPath.ts @@ -0,0 +1,27 @@ +import * as child_process from "child_process"; +import { join as pathJoin, resolve as pathResolve, sep as pathSep } from "path"; +import { assert } from "tsafe/assert"; + +export function getNpmWorkspaceRootDirPath(params: { reactAppRootDirPath: string }) { + const { reactAppRootDirPath } = params; + + const npmWorkspaceRootDirPath = (function callee(depth: number): string { + const cwd = pathResolve(pathJoin(...[reactAppRootDirPath, ...Array(depth).fill("..")])); + + try { + child_process.execSync("npm config get", { cwd: cwd }); + } catch (error) { + if (String(error).includes("ENOWORKSPACES")) { + assert(cwd !== pathSep, "NPM workspace not found"); + + return callee(depth + 1); + } + + throw error; + } + + return cwd; + })(0); + + return { npmWorkspaceRootDirPath }; +} diff --git a/src/bin/keycloakify/generateTheme/bringInAccountV1.ts b/src/bin/keycloakify/generateTheme/bringInAccountV1.ts index 6caaeabc..5fba86d8 100644 --- a/src/bin/keycloakify/generateTheme/bringInAccountV1.ts +++ b/src/bin/keycloakify/generateTheme/bringInAccountV1.ts @@ -11,6 +11,7 @@ import { rmSync } from "../../tools/fs.rmSync"; type BuildOptionsLike = { keycloakifyBuildDirPath: string; cacheDirPath: string; + npmWorkspaceRootDirPath: string; }; { diff --git a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts index 8cc0c3f0..811df236 100644 --- a/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts +++ b/src/bin/keycloakify/generateTheme/downloadKeycloakStaticResources.ts @@ -9,6 +9,7 @@ import { rmSync } from "../../tools/fs.rmSync"; export type BuildOptionsLike = { cacheDirPath: string; + npmWorkspaceRootDirPath: string; }; assert(); diff --git a/src/bin/keycloakify/generateTheme/generateTheme.ts b/src/bin/keycloakify/generateTheme/generateTheme.ts index 3ff03e79..3b1d026e 100644 --- a/src/bin/keycloakify/generateTheme/generateTheme.ts +++ b/src/bin/keycloakify/generateTheme/generateTheme.ts @@ -33,6 +33,7 @@ export type BuildOptionsLike = { urlPathname: string | undefined; doBuildRetrocompatAccountTheme: boolean; themeNames: string[]; + npmWorkspaceRootDirPath: string; }; assert(); diff --git a/src/bin/tools/downloadAndUnzip.ts b/src/bin/tools/downloadAndUnzip.ts deleted file mode 100644 index 2ea775f4..00000000 --- a/src/bin/tools/downloadAndUnzip.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { exec as execCallback } from "child_process"; -import { createHash } from "crypto"; -import { mkdir, readFile, stat, writeFile, unlink } from "fs/promises"; -import fetch, { type FetchOptions } from "make-fetch-happen"; -import { dirname as pathDirname, join as pathJoin, resolve as pathResolve, sep as pathSep, basename as pathBasename } from "path"; -import { assert } from "tsafe/assert"; -import { promisify } from "util"; -import { transformCodebase } from "./transformCodebase"; -import { unzip, zip } from "./unzip"; -import { rm } from "../tools/fs.rm"; -import * as child_process from "child_process"; - -const exec = promisify(execCallback); - -function generateFileNameFromURL(params: { - url: string; - preCacheTransform: - | { - actionCacheId: string; - actionFootprint: string; - } - | undefined; -}): string { - const { preCacheTransform } = params; - - // Parse the URL - const url = new URL(params.url); - - // Extract pathname and remove leading slashes - let fileName = url.pathname.replace(/^\//, "").replace(/\//g, "_"); - - // Optionally, add query parameters replacing special characters - if (url.search) { - fileName += url.search.replace(/[&=?]/g, "-"); - } - - // Replace any characters that are not valid in filenames - fileName = fileName.replace(/[^a-zA-Z0-9-_]/g, ""); - - // Trim or pad the fileName to a specific length - fileName = fileName.substring(0, 50); - - add_pre_cache_transform: { - if (preCacheTransform === undefined) { - break add_pre_cache_transform; - } - - // Sanitize actionCacheId the same way as other components - const sanitizedActionCacheId = preCacheTransform.actionCacheId.replace(/[^a-zA-Z0-9-_]/g, "_"); - - fileName += `_${sanitizedActionCacheId}_${createHash("sha256").update(preCacheTransform.actionFootprint).digest("hex").substring(0, 5)}`; - } - - return fileName; -} - -async function exists(path: string) { - try { - await stat(path); - return true; - } catch (error) { - if ((error as Error & { code: string }).code === "ENOENT") return false; - throw error; - } -} - -function ensureArray(arg0: T | T[]) { - return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0]; -} - -function ensureSingleOrNone(arg0: T | T[]) { - if (!Array.isArray(arg0)) return arg0; - if (arg0.length === 0) return undefined; - if (arg0.length === 1) return arg0[0]; - throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", ")); -} - -type NPMConfig = Record; - -const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) => - key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value }; - -/** - * Get npm configuration as map - */ -async function getNmpConfig() { - return readNpmConfig().then(parseNpmConfig); -} - -function readNpmConfig(): Promise { - return (async function callee(depth: number): Promise { - const cwd = pathResolve(pathJoin(...[process.cwd(), ...Array(depth).fill("..")])); - - let stdout: string; - - try { - stdout = await exec("npm config get", { "encoding": "utf8", cwd }).then(({ stdout }) => stdout); - } catch (error) { - if (String(error).includes("ENOWORKSPACES")) { - assert(cwd !== pathSep); - - return callee(depth + 1); - } - - throw error; - } - - return stdout; - })(0); -} - -function parseNpmConfig(stdout: string) { - return stdout - .split("\n") - .filter(line => !line.startsWith(";")) - .map(line => line.trim()) - .map(line => line.split("=", 2) as [string, string]) - .reduce(npmConfigReducer, {} as NPMConfig); -} - -function maybeBoolean(arg0: string | undefined) { - return typeof arg0 === "undefined" ? undefined : Boolean(arg0); -} - -function chunks(arr: T[], size: number = 2) { - return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][]; -} - -async function readCafile(cafile: string) { - const cafileContent = await readFile(cafile, "utf-8"); - return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n")); -} - -/** - * Get proxy and ssl configuration from npm config files. Note that we don't care about - * proxy config in env vars, because make-fetch-happen will do that for us. - * - * @returns proxy configuration - */ -async function getFetchOptions(): Promise> { - const cfg = await getNmpConfig(); - - const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]); - const noProxy = cfg["noproxy"] ?? cfg["no-proxy"]; - const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"])); - const cert = cfg["cert"]; - const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]); - const cafile = ensureSingleOrNone(cfg["cafile"]); - - if (typeof cafile !== "undefined" && cafile !== "null") ca.push(...(await readCafile(cafile))); - - return { proxy, noProxy, strictSSL, cert, ca: ca.length === 0 ? undefined : ca }; -} - -export async function downloadAndUnzip( - params: { - url: string; - destDirPath: string; - specificDirsToExtract?: string[]; - preCacheTransform?: { - actionCacheId: string; - action: (params: { destDirPath: string }) => Promise; - }; - } & ( - | { - doUseCache: true; - cacheDirPath: string; - } - | { - doUseCache: false; - } - ) -) { - const { url, destDirPath, specificDirsToExtract, preCacheTransform, ...rest } = params; - - const zipFileBasename = generateFileNameFromURL({ - url, - "preCacheTransform": - preCacheTransform === undefined - ? undefined - : { - "actionCacheId": preCacheTransform.actionCacheId, - "actionFootprint": preCacheTransform.action.toString() - } - }); - - const cacheDirPath = !rest.doUseCache ? `tmp_${Math.random().toString().slice(2, 12)}` : rest.cacheDirPath; - - const zipFilePath = pathJoin(cacheDirPath, `${zipFileBasename}.zip`); - const extractDirPath = pathJoin(cacheDirPath, `tmp_unzip_${zipFileBasename}`); - - download_zip_and_transform: { - if (await exists(zipFilePath)) { - break download_zip_and_transform; - } - - const opts = await getFetchOptions(); - - const { response, isFromRemoteCache } = await (async () => { - const response = await fetch(`https://github.com/keycloakify/keycloakify/releases/download/v0.0.1/${pathBasename(zipFilePath)}`, opts); - - if (response.status === 200) { - return { - response, - "isFromRemoteCache": true - }; - } - - return { - "response": await fetch(url, opts), - "isFromRemoteCache": false - }; - })(); - - await mkdir(pathDirname(zipFilePath), { "recursive": true }); - - /** - * The correct way to fix this is to upgrade node-fetch beyond 3.2.5 - * (see https://github.com/node-fetch/node-fetch/issues/1295#issuecomment-1144061991.) - * Unfortunately, octokit (a dependency of keycloakify) also uses node-fetch, and - * does not support node-fetch 3.x. So we stick around with this band-aid until - * octokit upgrades. - */ - response.body?.setMaxListeners(Number.MAX_VALUE); - assert(typeof response.body !== "undefined" && response.body != null); - - await writeFile(zipFilePath, response.body); - - if (isFromRemoteCache) { - break download_zip_and_transform; - } - - if (specificDirsToExtract === undefined && preCacheTransform === undefined) { - break download_zip_and_transform; - } - - await unzip(zipFilePath, extractDirPath, specificDirsToExtract); - - try { - await preCacheTransform?.action({ - "destDirPath": extractDirPath - }); - } catch (error) { - await Promise.all([rm(extractDirPath, { "recursive": true }), unlink(zipFilePath)]); - - throw error; - } - - await unlink(zipFilePath); - - await zip(extractDirPath, zipFilePath); - - await rm(extractDirPath, { "recursive": true }); - - upload_to_remot_cache_if_admin: { - const githubToken = process.env["KEYCLOAKIFY_ADMIN_GITHUB_PERSONAL_ACCESS_TOKEN"]; - - if (githubToken === undefined) { - break upload_to_remot_cache_if_admin; - } - - console.log("uploading to remote cache"); - - try { - child_process.execSync(`which putasset`); - } catch { - child_process.execSync(`npm install -g putasset`); - } - - try { - child_process.execFileSync("putasset", [ - "--owner", - "keycloakify", - "--repo", - "keycloakify", - "--tag", - "v0.0.1", - "--filename", - zipFilePath, - "--token", - githubToken - ]); - } catch { - console.log("upload failed, asset probably already exists in remote cache"); - } - } - } - - await unzip(zipFilePath, extractDirPath); - - transformCodebase({ - "srcDirPath": extractDirPath, - "destDirPath": destDirPath - }); - - if (!rest.doUseCache) { - await rm(cacheDirPath, { "recursive": true }); - } else { - await rm(extractDirPath, { "recursive": true }); - } -} diff --git a/src/bin/tools/fetchProxyOptions.ts b/src/bin/tools/fetchProxyOptions.ts new file mode 100644 index 00000000..a6e880fd --- /dev/null +++ b/src/bin/tools/fetchProxyOptions.ts @@ -0,0 +1,73 @@ +import { exec as execCallback } from "child_process"; +import { readFile } from "fs/promises"; +import { type FetchOptions } from "make-fetch-happen"; +import { promisify } from "util"; + +function ensureArray(arg0: T | T[]) { + return Array.isArray(arg0) ? arg0 : typeof arg0 === "undefined" ? [] : [arg0]; +} + +function ensureSingleOrNone(arg0: T | T[]) { + if (!Array.isArray(arg0)) return arg0; + if (arg0.length === 0) return undefined; + if (arg0.length === 1) return arg0[0]; + throw new Error("Illegal configuration, expected a single value but found multiple: " + arg0.map(String).join(", ")); +} + +type NPMConfig = Record; + +/** + * Get npm configuration as map + */ +async function getNmpConfig(params: { npmWorkspaceRootDirPath: string }) { + const { npmWorkspaceRootDirPath } = params; + + const exec = promisify(execCallback); + + const stdout = await exec("npm config get", { "encoding": "utf8", "cwd": npmWorkspaceRootDirPath }).then(({ stdout }) => stdout); + + const npmConfigReducer = (cfg: NPMConfig, [key, value]: [string, string]) => + key in cfg ? { ...cfg, [key]: [...ensureArray(cfg[key]), value] } : { ...cfg, [key]: value }; + + return stdout + .split("\n") + .filter(line => !line.startsWith(";")) + .map(line => line.trim()) + .map(line => line.split("=", 2) as [string, string]) + .reduce(npmConfigReducer, {} as NPMConfig); +} + +export type ProxyFetchOptions = Pick; + +export async function getProxyFetchOptions(params: { npmWorkspaceRootDirPath: string }): Promise { + const { npmWorkspaceRootDirPath } = params; + + const cfg = await getNmpConfig({ npmWorkspaceRootDirPath }); + + const proxy = ensureSingleOrNone(cfg["https-proxy"] ?? cfg["proxy"]); + const noProxy = cfg["noproxy"] ?? cfg["no-proxy"]; + + function maybeBoolean(arg0: string | undefined) { + return typeof arg0 === "undefined" ? undefined : Boolean(arg0); + } + + const strictSSL = maybeBoolean(ensureSingleOrNone(cfg["strict-ssl"])); + const cert = cfg["cert"]; + const ca = ensureArray(cfg["ca"] ?? cfg["ca[]"]); + const cafile = ensureSingleOrNone(cfg["cafile"]); + + if (typeof cafile !== "undefined" && cafile !== "null") { + ca.push( + ...(await (async () => { + function chunks(arr: T[], size: number = 2) { + return arr.map((_, i) => i % size == 0 && arr.slice(i, i + size)).filter(Boolean) as T[][]; + } + + const cafileContent = await readFile(cafile, "utf-8"); + return chunks(cafileContent.split(/(-----END CERTIFICATE-----)/), 2).map(ca => ca.join("").replace(/^\n/, "").replace(/\n/g, "\\n")); + })()) + ); + } + + return { proxy, noProxy, strictSSL, cert, "ca": ca.length === 0 ? undefined : ca }; +} diff --git a/src/bin/tools/fs.existsAsync.ts b/src/bin/tools/fs.existsAsync.ts new file mode 100644 index 00000000..359caabd --- /dev/null +++ b/src/bin/tools/fs.existsAsync.ts @@ -0,0 +1,11 @@ +import * as fs from "fs/promises"; + +export async function existsAsync(path: string) { + try { + await fs.stat(path); + return true; + } catch (error) { + if ((error as Error & { code: string }).code === "ENOENT") return false; + throw error; + } +}