UNPKG

@pagopa/dx-cli

Version:

A CLI useful to manage DX tools.

535 lines (506 loc) 17.9 kB
#!/usr/bin/env node // src/index.ts import "core-js/actual/set/index.js"; import { configure, getConsoleSink, getLogger as getLogger4 } from "@logtape/logtape"; // src/adapters/commander/index.ts import { Command as Command4 } from "commander"; // src/adapters/commander/commands/doctor.ts import { Command } from "commander"; import * as process2 from "process"; // src/domain/doctor.ts import { ResultAsync as ResultAsync3 } from "neverthrow"; // src/domain/package-json.ts import { err, ok } from "neverthrow"; import { z } from "zod/v4"; var ScriptName = z.string().brand(); var scriptSchema = z.object({ name: ScriptName, script: z.string() }); var DependencyName = z.string().brand(); var dependencySchema = z.object({ name: DependencyName, version: z.string() }); var PackageName = z.string().min(1).brand(); var scriptsSchema = z.record(ScriptName, z.string()).optional().transform( (obj) => new Map( obj ? Object.entries(obj).map(([name, script]) => [ ScriptName.parse(name), script ]) : [] ) ); var dependenciesSchema = z.record(DependencyName, z.string()).optional().transform( (obj) => new Map( obj ? Object.entries(obj).map(([name, version]) => [ DependencyName.parse(name), version ]) : [] ) ); var packageManagerSchema = z.enum(["npm", "pnpm", "yarn"]); var packageJsonSchema = z.object({ dependencies: dependenciesSchema, devDependencies: dependenciesSchema, name: PackageName, packageManager: z.string().transform((str) => str.split("@")[0]).pipe(packageManagerSchema).optional(), scripts: scriptsSchema }); var findMissingScripts = (availableScripts, requiredScripts) => { const availableScriptNames = new Set(availableScripts.keys()); const requiredScriptNames = new Set(requiredScripts.keys()); return requiredScriptNames.difference(availableScriptNames); }; var checkMonorepoScripts = async (dependencies, config2) => { const { packageJsonReader: packageJsonReader2 } = dependencies; const checkName = "Monorepo Scripts"; const scriptsResult = await packageJsonReader2.getScripts( config2.repository.root ); if (scriptsResult.isErr()) { return err(scriptsResult.error); } const requiredScriptsMap = packageJsonReader2.getRootRequiredScripts(); const missingScripts = findMissingScripts( scriptsResult.value, requiredScriptsMap ); if (missingScripts.size === 0) { return ok({ checkName, isValid: true, successMessage: "Monorepo scripts are correctly set up" }); } return ok({ checkName, errorMessage: `Missing required scripts: ${Array.from(missingScripts).join(", ")}`, isValid: false }); }; // src/domain/repository.ts import { ok as ok2 } from "neverthrow"; import fs from "path"; import coerce from "semver/functions/coerce.js"; import semverGte from "semver/functions/gte.js"; var isVersionValid = (version, minVersion) => { const minAcceptedSemVer = coerce(minVersion); const dependencySemVer = coerce(version); if (!minAcceptedSemVer || !dependencySemVer) { return false; } return semverGte(dependencySemVer, minAcceptedSemVer); }; var checkPreCommitConfig = async (dependencies, config2) => { const { repositoryReader: repositoryReader2 } = dependencies; const checkName = "Pre-commit Configuration"; const preCommitResult = await repositoryReader2.fileExists( fs.join(config2.repository.root, ".pre-commit-config.yaml") ); if (preCommitResult.isOk() && preCommitResult.value) { return ok2({ checkName, isValid: true, successMessage: "Pre-commit configuration is present in the repository root" }); } const errorMessage = preCommitResult.isErr() ? preCommitResult.error.message : `Pre-commit configuration is not present in the repository root. Please add a .pre-commit-config.yaml file to the repository root.`; return ok2({ checkName, errorMessage, isValid: false }); }; var checkTurboConfig = async (dependencies, config2) => { const { packageJsonReader: packageJsonReader2, repositoryReader: repositoryReader2 } = dependencies; const checkName = "Turbo Configuration"; const repoRoot2 = config2.repository.root; const turboResult = await repositoryReader2.fileExists( fs.join(repoRoot2, "turbo.json") ); if (turboResult.isErr()) { return ok2({ checkName, errorMessage: turboResult.error.message, isValid: false }); } const dependenciesResult = await packageJsonReader2.getDependencies( repoRoot2, "dev" ); if (dependenciesResult.isErr()) { return ok2({ checkName, errorMessage: dependenciesResult.error.message, isValid: false }); } const turboVersion = dependenciesResult.value.get( "turbo" ); if (!turboVersion) { return ok2({ checkName, errorMessage: "Turbo dependency not found in devDependencies. Please add 'turbo' to your devDependencies.", isValid: false }); } if (!isVersionValid(turboVersion, config2.minVersions.turbo)) { return ok2({ checkName, errorMessage: `Turbo version (${turboVersion}) is too low. Minimum required version is ${config2.minVersions.turbo}.`, isValid: false }); } return ok2({ checkName, isValid: true, successMessage: "Turbo configuration is present in the monorepo root and turbo dependency is installed" }); }; // src/domain/workspace.ts import { ok as ok3 } from "neverthrow"; import { z as z2 } from "zod/v4"; var WorkspaceName = z2.string().min(1).brand(); var workspaceSchema = z2.object({ name: WorkspaceName, path: z2.string() }); var checkWorkspaces = async (dependencies, monorepoDir) => { const { repositoryReader: repositoryReader2 } = dependencies; const checkName = "Workspaces"; const workspacesResult = await repositoryReader2.getWorkspaces(monorepoDir); if (workspacesResult.isErr()) { return ok3({ checkName, errorMessage: "Something is wrong with the workspaces configuration. If you need help, please contact the DevEx team.", isValid: false }); } const { length: workspaceNumber } = workspacesResult.value; if (workspaceNumber === 0) { return ok3({ checkName, errorMessage: "No workspace configuration found. Make sure to configure workspaces in pnpm-workspace.yaml.", isValid: false }); } return ok3({ checkName, isValid: true, successMessage: `Found ${workspaceNumber} workspace${workspaceNumber === 1 ? "" : "s"}` }); }; // src/domain/doctor.ts var runDoctor = (dependencies, config2) => { const doctorChecks = [ ResultAsync3.fromPromise( checkPreCommitConfig(dependencies, config2), () => new Error("Error checking pre-commit configuration") ), ResultAsync3.fromPromise( checkTurboConfig(dependencies, config2), () => new Error("Error checking Turbo configuration") ), ResultAsync3.fromPromise( checkMonorepoScripts(dependencies, config2), () => new Error("Error checking monorepo scripts") ), ResultAsync3.fromPromise( checkWorkspaces(dependencies, config2.repository.root), () => new Error("Error checking monorepo scripts") ) ]; return ResultAsync3.combine(doctorChecks).match( toDoctorResult, () => ({ checks: [], hasErrors: true }) ); }; var toDoctorResult = (validationCheckResults) => { const checks = validationCheckResults.map((result) => { if (result.isOk()) { return result.value; } return { checkName: "Unknown", errorMessage: result.error.message, isValid: false }; }); const hasErrors = checks.some((check) => !check.isValid); return { checks, hasErrors }; }; var printDoctorResult = ({ validationReporter: validationReporter2 }, result) => result.checks.map(validationReporter2.reportCheckResult); // src/adapters/commander/commands/doctor.ts var makeDoctorCommand = (dependencies, config2) => new Command().name("doctor").description( "Verify the repository setup according to the DevEx guidelines" ).action(async () => { const result = await runDoctor(dependencies, config2); printDoctorResult(dependencies, result); const exitCode = result.hasErrors ? 1 : 0; process2.exit(exitCode); }); // src/adapters/commander/commands/info.ts import { Command as Command2 } from "commander"; // src/domain/info.ts import { getLogger } from "@logtape/logtape"; import { join } from "path"; var detectFromLockFile = async (dependencies, config2) => { const { repositoryReader: repositoryReader2 } = dependencies; const repoRoot2 = config2.repository.root; const pnpmResult = await repositoryReader2.fileExists( join(repoRoot2, "pnpm-lock.yaml") ); if (pnpmResult.isOk() && pnpmResult.value) return "pnpm"; const yarnResult = await repositoryReader2.fileExists( join(repoRoot2, "yarn.lock") ); if (yarnResult.isOk() && yarnResult.value) return "yarn"; const npmResult = await repositoryReader2.fileExists( join(repoRoot2, "package-lock.json") ); if (npmResult.isOk() && npmResult.value) return "npm"; return void 0; }; var detectPackageManager = async (dependencies, config2) => { const packageManager = dependencies.packageJson.packageManager ?? await detectFromLockFile(dependencies, config2); return packageManager ?? "npm"; }; var detectNodeVersion = async ({ repositoryReader: repositoryReader2 }, nodeVersionFilePath) => await repositoryReader2.readFile(nodeVersionFilePath).map((nodeVersion) => nodeVersion.trim()).unwrapOr(void 0); var detectTerraformVersion = async ({ repositoryReader: repositoryReader2 }, terraformVersionFilePath) => await repositoryReader2.readFile(terraformVersionFilePath).map((tfVersion) => tfVersion.trim()).unwrapOr(void 0); var detectTurboVersion = ({ devDependencies }) => devDependencies.get("turbo")?.trim(); var getInfo = async (dependencies, config2) => ({ node: await detectNodeVersion( { repositoryReader: dependencies.repositoryReader }, `${config2.repository.root}/.node-version` ), packageManager: await detectPackageManager(dependencies, config2), terraform: await detectTerraformVersion( { repositoryReader: dependencies.repositoryReader }, `${config2.repository.root}/.terraform-version` ), turbo: detectTurboVersion(dependencies.packageJson) }); var printInfo = (result) => { const logger2 = getLogger("json"); logger2.info(JSON.stringify(result)); }; // src/adapters/commander/commands/info.ts var makeInfoCommand = (dependencies, config2) => new Command2().name("info").description("Display information about the project").action(async () => { const result = await getInfo(dependencies, config2); printInfo(result); }); // src/adapters/commander/commands/version.ts import { Command as Command3 } from "commander"; // src/domain/version.ts import { getLogger as getLogger2 } from "@logtape/logtape"; function printVersion() { const logger2 = getLogger2(["dx-cli", "version"]); logger2.info(`dx CLI version: ${"0.4.3"}`); } // src/adapters/commander/commands/version.ts var makeVersionCommand = () => new Command3().name("version").alias("v").action(() => printVersion()); // src/adapters/commander/index.ts var makeCli = (deps2, config2) => { const program2 = new Command4(); program2.name("dx").description("The CLI for DX-Platform").version("0.4.3"); program2.addCommand(makeDoctorCommand(deps2, config2)); program2.addCommand(makeVersionCommand()); program2.addCommand(makeInfoCommand(deps2, config2)); return program2; }; // src/adapters/logtape/validation-reporter.ts import { getLogger as getLogger3 } from "@logtape/logtape"; var makeValidationReporter = () => { const logger2 = getLogger3(["dx-cli", "validation"]); return { reportCheckResult(result) { if (result.isValid) { logger2.info(`\u2705 ${result.successMessage}`); } else { logger2.error(`\u274C ${result.errorMessage}`); } } }; }; // src/adapters/node/package-json.ts import { join as join2 } from "path"; import * as process3 from "process"; // src/adapters/node/fs/file-reader.ts import { ResultAsync as ResultAsync5 } from "neverthrow"; import fs2 from "fs/promises"; // src/adapters/zod/index.ts import { ResultAsync as ResultAsync4 } from "neverthrow"; var decode = (schema) => ResultAsync4.fromThrowable( schema.parseAsync, (cause) => new Error("File content is not valid for the given schema", { cause }) ); // src/adapters/node/json/index.ts import { Result } from "neverthrow"; var parseJson = Result.fromThrowable( JSON.parse, (cause) => new Error("Failed to parse JSON", { cause }) ); // src/adapters/node/fs/file-reader.ts var readFile = (filePath) => ResultAsync5.fromPromise( fs2.readFile(filePath, "utf-8"), (cause) => new Error(`Failed to read file: ${filePath}`, { cause }) ); var readFileAndDecode = (filePath, schema) => readFile(filePath).andThen(parseJson).andThen(decode(schema)); var fileExists = (path2) => ResultAsync5.fromPromise( fs2.stat(path2), () => new Error(`${path2} not found.`) ).map(() => true); // src/adapters/node/package-json.ts var makePackageJsonReader = () => ({ getDependencies: (cwd2 = process3.cwd(), type) => { const packageJsonPath = join2(cwd2, "package.json"); return readFileAndDecode(packageJsonPath, packageJsonSchema).map( (packageJson2) => { const key = type === "dev" ? "devDependencies" : "dependencies"; return packageJson2[key]; } ); }, getRootRequiredScripts: () => (/* @__PURE__ */ new Map()).set("code-review", "eslint ."), getScripts: (cwd2 = process3.cwd()) => { const packageJsonPath = join2(cwd2, "package.json"); return readFileAndDecode(packageJsonPath, packageJsonSchema).map( ({ scripts }) => scripts ); }, readPackageJson: (cwd2 = process3.cwd()) => { const packageJsonPath = join2(cwd2, "package.json"); return readFileAndDecode(packageJsonPath, packageJsonSchema); } }); // src/adapters/node/repository.ts import * as glob from "glob"; import { okAsync, ResultAsync as ResultAsync6 } from "neverthrow"; import * as path from "path"; import { z as z3 } from "zod/v4"; // src/adapters/yaml/index.ts import { Result as Result2 } from "neverthrow"; import yaml from "yaml"; var parseYaml = Result2.fromThrowable( (content) => yaml.parse(content), () => new Error("Failed to parse YAML") ); // src/adapters/node/repository.ts var findRepositoryRoot = (dir = process.cwd()) => { const gitPath = path.join(dir, ".git"); return fileExists(gitPath).mapErr( () => new Error( "Could not find repository root. Make sure to have the repo initialized." ) ).map(() => dir); }; var resolveWorkspacePattern = (repoRoot2, pattern) => ResultAsync6.fromPromise( // For now it is not possible to use the fs.glob function (from node:fs/promises) // because it is not possible to run it on Node 20.x glob.glob(pattern, { cwd: repoRoot2 }), (cause) => new Error(`Failed to resolve workspace glob: ${pattern}`, { cause }) ).map( (subDirectories) => ( // Create the absolute path to the subdirectory subDirectories.map((directory) => path.join(repoRoot2, directory)) ) ); var getWorkspaces = (repoRoot2) => readFile(path.join(repoRoot2, "pnpm-workspace.yaml")).andThen(parseYaml).andThen( (obj) => ( // If no packages are defined, go on with an empty array decode(z3.object({ packages: z3.array(z3.string()) }))(obj).orElse( () => okAsync({ packages: [] }) ) ) ).andThen( ({ packages }) => ( // For every package pattern in the pnpm-workspace.yaml file, get the list of subdirectories ResultAsync6.combine( packages.map((pattern) => resolveWorkspacePattern(repoRoot2, pattern)) ).map((workspacesList) => workspacesList.flat()).andThen((workspaceFolders) => { const workspaceResults = workspaceFolders.map( (nodeWorkspaceDirectory) => readFileAndDecode( path.join(nodeWorkspaceDirectory, "package.json"), packageJsonSchema ).map( ({ name }) => ( // Create the workspace object using the package.json name and the nodeWorkspaceDirectory workspaceSchema.parse({ name, path: nodeWorkspaceDirectory }) ) ) ); return ResultAsync6.combine(workspaceResults); }) ) ); var makeRepositoryReader = () => ({ fileExists, findRepositoryRoot, getWorkspaces, readFile }); // src/config.ts var getConfig = (repositoryRoot2) => ({ minVersions: { turbo: "2" }, repository: { root: repositoryRoot2 } }); // src/index.ts await configure({ loggers: [ { category: ["dx-cli"], lowestLevel: "info", sinks: ["console"] }, { category: ["json"], lowestLevel: "info", sinks: ["rawJson"] }, { category: ["logtape", "meta"], lowestLevel: "warning", sinks: ["console"] } ], sinks: { console: getConsoleSink(), rawJson(record) { console.log(record.rawMessage); } } }); var logger = getLogger4(["dx-cli"]); var repositoryReader = makeRepositoryReader(); var packageJsonReader = makePackageJsonReader(); var validationReporter = makeValidationReporter(); var repoRoot = await repositoryReader.findRepositoryRoot(process.cwd()); if (repoRoot.isErr()) { logger.error( "Could not find repository root. Make sure to have the repo initialized." ); process.exit(1); } var repositoryRoot = repoRoot.value; var repoPackageJson = await packageJsonReader.readPackageJson(repositoryRoot); if (repoPackageJson.isErr()) { logger.error("Repository does not contain a package.json file"); process.exit(1); } var packageJson = repoPackageJson.value; var deps = { packageJson, packageJsonReader, repositoryReader, validationReporter }; var config = getConfig(repositoryRoot); var program = makeCli(deps, config); program.parse();