@pagopa/dx-cli
Version:
A CLI useful to manage DX tools.
535 lines (506 loc) • 17.9 kB
JavaScript
// 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();