@pagopa/dx-cli 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +85 -0
  2. package/bin/index.js +445 -0
  3. package/package.json +55 -0
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @pagopa/dx-cli
2
+
3
+ <div align="center">
4
+
5
+ <img src="../../assets/pagopa-logo.png" width="100" alt="PagoPA logo">
6
+
7
+ # DX CLI
8
+
9
+ <p align="center">
10
+ <i align="center">A CLI tool for managing DevEx (Developer Experience) guidelines and best practices 🚀</i>
11
+ </p>
12
+
13
+ </div>
14
+
15
+ ## 📖 Overview
16
+
17
+ The DX CLI is a command-line tool designed to help developers manage and validate their development setup according to PagoPA's DevEx guidelines. It provides automated checks and validations to ensure repositories follow the established best practices and conventions.
18
+
19
+ ## ✨ Features
20
+
21
+ - **Repository Validation**: Verify repository setup against DevEx guidelines
22
+ - **Monorepo Script Checking**: Validate that required scripts are present in package.json
23
+ - **Developer Experience Optimization**: Ensure consistent development practices across projects
24
+
25
+ ## 🚀 Installation
26
+
27
+ > [!NOTE]
28
+ > The CLI is currently only available locally and is not yet distributed through package managers.
29
+
30
+ ### From Source (Development)
31
+
32
+ ```bash
33
+ # Clone the repository
34
+ git clone https://github.com/pagopa/dx.git
35
+ cd dx
36
+
37
+ # Install dependencies
38
+ yarn install
39
+
40
+ # Build the CLI
41
+ yarn build
42
+
43
+ # Run the CLI
44
+ node ./apps/cli/bin/index.js --help
45
+ ```
46
+
47
+ ## 🛠️ Usage
48
+
49
+ ### Available Commands
50
+
51
+ #### `doctor`
52
+
53
+ Verify the repository setup according to the DevEx guidelines.
54
+
55
+ ```bash
56
+ dx doctor
57
+ ```
58
+
59
+ This command will:
60
+
61
+ - Check if you're in a valid Git repository
62
+ - Validate that required monorepo scripts are present in package.json
63
+ - Check that the `turbo.json` file exists
64
+ - Verify that the installed `turbo` version meets the minimum requirements
65
+
66
+ **Example output:**
67
+
68
+ ```bash
69
+ $ dx doctor
70
+ Checking monorepo scripts...
71
+ ✅ Monorepo scripts are correctly set up
72
+ ```
73
+
74
+ ### Global Options
75
+
76
+ - `--version, -V`: Display version number
77
+ - `--help, -h`: Display help information
78
+
79
+ ---
80
+
81
+ <div align="center">
82
+
83
+ Made with ❤️ by the PagoPA DevEx Team
84
+
85
+ </div>
package/bin/index.js ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import "core-js/actual/set/index.js";
5
+ import { configure, getConsoleSink, getLogger as getLogger4 } from "@logtape/logtape";
6
+
7
+ // src/adapters/commander/index.ts
8
+ import { Command as Command4 } from "commander";
9
+
10
+ // src/adapters/commander/commands/doctor.ts
11
+ import { Command } from "commander";
12
+ import * as process2 from "process";
13
+
14
+ // src/domain/doctor.ts
15
+ import { ResultAsync as ResultAsync3 } from "neverthrow";
16
+
17
+ // src/domain/package-json.ts
18
+ import { err, ok } from "neverthrow";
19
+ import { z } from "zod/v4";
20
+ var ScriptName = z.string().brand();
21
+ var scriptSchema = z.object({
22
+ name: ScriptName,
23
+ script: z.string()
24
+ });
25
+ var DependencyName = z.string().brand();
26
+ var dependencySchema = z.object({
27
+ name: DependencyName,
28
+ version: z.string()
29
+ });
30
+ var PackageName = z.string().min(1).brand();
31
+ var scriptsSchema = z.record(ScriptName, z.string()).optional().transform(
32
+ (obj) => new Map(
33
+ obj ? Object.entries(obj).map(([name, script]) => [
34
+ ScriptName.parse(name),
35
+ script
36
+ ]) : []
37
+ )
38
+ );
39
+ var dependenciesSchema = z.record(DependencyName, z.string()).optional().transform(
40
+ (obj) => new Map(
41
+ obj ? Object.entries(obj).map(([name, version]) => [
42
+ DependencyName.parse(name),
43
+ version
44
+ ]) : []
45
+ )
46
+ );
47
+ var packageManagerSchema = z.enum(["npm", "pnpm", "yarn"]);
48
+ var packageJsonSchema = z.object({
49
+ dependencies: dependenciesSchema,
50
+ devDependencies: dependenciesSchema,
51
+ name: PackageName,
52
+ packageManager: z.string().transform((str) => str.split("@")[0]).pipe(packageManagerSchema).optional(),
53
+ scripts: scriptsSchema
54
+ });
55
+ var findMissingScripts = (availableScripts, requiredScripts) => {
56
+ const availableScriptNames = new Set(availableScripts.keys());
57
+ const requiredScriptNames = new Set(requiredScripts.keys());
58
+ return requiredScriptNames.difference(availableScriptNames);
59
+ };
60
+ var checkMonorepoScripts = async (dependencies, config2) => {
61
+ const { packageJsonReader: packageJsonReader2 } = dependencies;
62
+ const checkName = "Monorepo Scripts";
63
+ const scriptsResult = await packageJsonReader2.getScripts(
64
+ config2.repository.root
65
+ );
66
+ if (scriptsResult.isErr()) {
67
+ return err(scriptsResult.error);
68
+ }
69
+ const requiredScriptsMap = packageJsonReader2.getRootRequiredScripts();
70
+ const missingScripts = findMissingScripts(
71
+ scriptsResult.value,
72
+ requiredScriptsMap
73
+ );
74
+ if (missingScripts.size === 0) {
75
+ return ok({
76
+ checkName,
77
+ isValid: true,
78
+ successMessage: "Monorepo scripts are correctly set up"
79
+ });
80
+ }
81
+ return ok({
82
+ checkName,
83
+ errorMessage: `Missing required scripts: ${Array.from(missingScripts).join(", ")}`,
84
+ isValid: false
85
+ });
86
+ };
87
+
88
+ // src/domain/repository.ts
89
+ import { ok as ok2 } from "neverthrow";
90
+ import fs from "path";
91
+ import coerce from "semver/functions/coerce.js";
92
+ import semverGte from "semver/functions/gte.js";
93
+ var isVersionValid = (version, minVersion) => {
94
+ const minAcceptedSemVer = coerce(minVersion);
95
+ const dependencySemVer = coerce(version);
96
+ if (!minAcceptedSemVer || !dependencySemVer) {
97
+ return false;
98
+ }
99
+ return semverGte(dependencySemVer, minAcceptedSemVer);
100
+ };
101
+ var checkPreCommitConfig = async (dependencies, config2) => {
102
+ const { repositoryReader: repositoryReader2 } = dependencies;
103
+ const checkName = "Pre-commit Configuration";
104
+ const preCommitResult = await repositoryReader2.fileExists(
105
+ fs.join(config2.repository.root, ".pre-commit-config.yaml")
106
+ );
107
+ if (preCommitResult.isOk() && preCommitResult.value) {
108
+ return ok2({
109
+ checkName,
110
+ isValid: true,
111
+ successMessage: "Pre-commit configuration is present in the repository root"
112
+ });
113
+ }
114
+ 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.`;
115
+ return ok2({
116
+ checkName,
117
+ errorMessage,
118
+ isValid: false
119
+ });
120
+ };
121
+ var checkTurboConfig = async (dependencies, config2) => {
122
+ const { packageJsonReader: packageJsonReader2, repositoryReader: repositoryReader2 } = dependencies;
123
+ const checkName = "Turbo Configuration";
124
+ const repoRoot2 = config2.repository.root;
125
+ const turboResult = await repositoryReader2.fileExists(
126
+ fs.join(repoRoot2, "turbo.json")
127
+ );
128
+ if (turboResult.isErr()) {
129
+ return ok2({
130
+ checkName,
131
+ errorMessage: turboResult.error.message,
132
+ isValid: false
133
+ });
134
+ }
135
+ const dependenciesResult = await packageJsonReader2.getDependencies(
136
+ repoRoot2,
137
+ "dev"
138
+ );
139
+ if (dependenciesResult.isErr()) {
140
+ return ok2({
141
+ checkName,
142
+ errorMessage: dependenciesResult.error.message,
143
+ isValid: false
144
+ });
145
+ }
146
+ const turboVersion = dependenciesResult.value.get(
147
+ "turbo"
148
+ );
149
+ if (!turboVersion) {
150
+ return ok2({
151
+ checkName,
152
+ errorMessage: "Turbo dependency not found in devDependencies. Please add 'turbo' to your devDependencies.",
153
+ isValid: false
154
+ });
155
+ }
156
+ if (!isVersionValid(turboVersion, config2.minVersions.turbo)) {
157
+ return ok2({
158
+ checkName,
159
+ errorMessage: `Turbo version (${turboVersion}) is too low. Minimum required version is ${config2.minVersions.turbo}.`,
160
+ isValid: false
161
+ });
162
+ }
163
+ return ok2({
164
+ checkName,
165
+ isValid: true,
166
+ successMessage: "Turbo configuration is present in the monorepo root and turbo dependency is installed"
167
+ });
168
+ };
169
+
170
+ // src/domain/doctor.ts
171
+ var runDoctor = (dependencies, config2) => {
172
+ const doctorChecks = [
173
+ ResultAsync3.fromPromise(
174
+ checkPreCommitConfig(dependencies, config2),
175
+ () => new Error("Error checking pre-commit configuration")
176
+ ),
177
+ ResultAsync3.fromPromise(
178
+ checkTurboConfig(dependencies, config2),
179
+ () => new Error("Error checking Turbo configuration")
180
+ ),
181
+ ResultAsync3.fromPromise(
182
+ checkMonorepoScripts(dependencies, config2),
183
+ () => new Error("Error checking monorepo scripts")
184
+ )
185
+ ];
186
+ return ResultAsync3.combine(doctorChecks).match(
187
+ toDoctorResult,
188
+ () => ({
189
+ checks: [],
190
+ hasErrors: true
191
+ })
192
+ );
193
+ };
194
+ var toDoctorResult = (validationCheckResults) => {
195
+ const checks = validationCheckResults.map((result) => {
196
+ if (result.isOk()) {
197
+ return result.value;
198
+ }
199
+ return {
200
+ checkName: "Unknown",
201
+ errorMessage: result.error.message,
202
+ isValid: false
203
+ };
204
+ });
205
+ const hasErrors = checks.some((check) => !check.isValid);
206
+ return {
207
+ checks,
208
+ hasErrors
209
+ };
210
+ };
211
+ var printDoctorResult = ({ validationReporter: validationReporter2 }, result) => result.checks.map(validationReporter2.reportCheckResult);
212
+
213
+ // src/adapters/commander/commands/doctor.ts
214
+ var makeDoctorCommand = (dependencies, config2) => new Command().name("doctor").description(
215
+ "Verify the repository setup according to the DevEx guidelines"
216
+ ).action(async () => {
217
+ const result = await runDoctor(dependencies, config2);
218
+ printDoctorResult(dependencies, result);
219
+ const exitCode = result.hasErrors ? 1 : 0;
220
+ process2.exit(exitCode);
221
+ });
222
+
223
+ // src/adapters/commander/commands/info.ts
224
+ import { Command as Command2 } from "commander";
225
+
226
+ // src/domain/info.ts
227
+ import { getLogger } from "@logtape/logtape";
228
+ import { join } from "path";
229
+ var detectFromLockFile = async (dependencies, config2) => {
230
+ const { repositoryReader: repositoryReader2 } = dependencies;
231
+ const repoRoot2 = config2.repository.root;
232
+ const pnpmResult = await repositoryReader2.fileExists(
233
+ join(repoRoot2, "pnpm-lock.yaml")
234
+ );
235
+ if (pnpmResult.isOk() && pnpmResult.value) return "pnpm";
236
+ const yarnResult = await repositoryReader2.fileExists(
237
+ join(repoRoot2, "yarn.lock")
238
+ );
239
+ if (yarnResult.isOk() && yarnResult.value) return "yarn";
240
+ const npmResult = await repositoryReader2.fileExists(
241
+ join(repoRoot2, "package-lock.json")
242
+ );
243
+ if (npmResult.isOk() && npmResult.value) return "npm";
244
+ return void 0;
245
+ };
246
+ var detectPackageManager = async (dependencies, config2) => {
247
+ const packageManager = dependencies.packageJson.packageManager ?? await detectFromLockFile(dependencies, config2);
248
+ return packageManager ?? "npm";
249
+ };
250
+ var detectNodeVersion = async ({ repositoryReader: repositoryReader2 }, nodeVersionFilePath) => await repositoryReader2.readFile(nodeVersionFilePath).unwrapOr(void 0);
251
+ var detectTerraformVersion = async ({ repositoryReader: repositoryReader2 }, terraformVersionFilePath) => await repositoryReader2.readFile(terraformVersionFilePath).map((tfVersion) => tfVersion.trim()).unwrapOr(void 0);
252
+ var getInfo = async (dependencies, config2) => ({
253
+ node: await detectNodeVersion(
254
+ { repositoryReader: dependencies.repositoryReader },
255
+ `${config2.repository.root}/.node-version`
256
+ ),
257
+ packageManager: await detectPackageManager(dependencies, config2),
258
+ terraform: await detectTerraformVersion(
259
+ dependencies,
260
+ `${config2.repository.root}/.terraform-version`
261
+ )
262
+ });
263
+ var printInfo = (result) => {
264
+ const logger2 = getLogger("json");
265
+ logger2.info(JSON.stringify(result));
266
+ };
267
+
268
+ // src/adapters/commander/commands/info.ts
269
+ var makeInfoCommand = (dependencies, config2) => new Command2().name("info").description("Display information about the project").action(async () => {
270
+ const result = await getInfo(dependencies, config2);
271
+ printInfo(result);
272
+ });
273
+
274
+ // src/adapters/commander/commands/version.ts
275
+ import { Command as Command3 } from "commander";
276
+
277
+ // src/domain/version.ts
278
+ import { getLogger as getLogger2 } from "@logtape/logtape";
279
+ function printVersion() {
280
+ const logger2 = getLogger2(["dx-cli", "version"]);
281
+ logger2.info(`dx CLI version: ${"0.4.1"}`);
282
+ }
283
+
284
+ // src/adapters/commander/commands/version.ts
285
+ var makeVersionCommand = () => new Command3().name("version").alias("v").action(() => printVersion());
286
+
287
+ // src/adapters/commander/index.ts
288
+ var makeCli = (deps2, config2) => {
289
+ const program2 = new Command4();
290
+ program2.name("dx").description("The CLI for DX-Platform").version("0.4.1");
291
+ program2.addCommand(makeDoctorCommand(deps2, config2));
292
+ program2.addCommand(makeVersionCommand());
293
+ program2.addCommand(makeInfoCommand(deps2, config2));
294
+ return program2;
295
+ };
296
+
297
+ // src/adapters/logtape/validation-reporter.ts
298
+ import { getLogger as getLogger3 } from "@logtape/logtape";
299
+ var makeValidationReporter = () => {
300
+ const logger2 = getLogger3(["dx-cli", "validation"]);
301
+ return {
302
+ reportCheckResult(result) {
303
+ if (result.isValid) {
304
+ logger2.info(`\u2705 ${result.successMessage}`);
305
+ } else {
306
+ logger2.error(`\u274C ${result.errorMessage}`);
307
+ }
308
+ },
309
+ reportValidationResult(result) {
310
+ if (result.isOk()) {
311
+ const validation = result.value;
312
+ if (validation.isValid) {
313
+ logger2.info(`\u2705 ${validation.successMessage}`);
314
+ } else {
315
+ logger2.error(`\u274C ${validation.errorMessage}`);
316
+ }
317
+ } else {
318
+ logger2.error(`\u274C ${result.error.message}`);
319
+ }
320
+ }
321
+ };
322
+ };
323
+
324
+ // src/adapters/node/package-json.ts
325
+ import { join as join2 } from "path";
326
+ import * as process3 from "process";
327
+
328
+ // src/adapters/node/fs/file-reader.ts
329
+ import { Result, ResultAsync as ResultAsync4 } from "neverthrow";
330
+ import fs2 from "fs/promises";
331
+ var readFile = (filePath) => ResultAsync4.fromPromise(
332
+ fs2.readFile(filePath, "utf-8"),
333
+ (cause) => new Error(`Failed to read file: ${filePath}`, { cause })
334
+ );
335
+ var readFileAndDecode = (filePath, schema) => {
336
+ const decode = ResultAsync4.fromThrowable(
337
+ schema.parseAsync,
338
+ () => new Error("File content is not valid for the given schema")
339
+ );
340
+ const toJSON = Result.fromThrowable(
341
+ JSON.parse,
342
+ () => new Error("Failed to parse JSON")
343
+ );
344
+ return readFile(filePath).andThen(toJSON).andThen(decode);
345
+ };
346
+ var fileExists = (path) => ResultAsync4.fromPromise(
347
+ fs2.stat(path),
348
+ () => new Error(`${path} not found.`)
349
+ ).map(() => true);
350
+
351
+ // src/adapters/node/package-json.ts
352
+ var makePackageJsonReader = () => ({
353
+ getDependencies: (cwd2 = process3.cwd(), type) => {
354
+ const packageJsonPath = join2(cwd2, "package.json");
355
+ return readFileAndDecode(packageJsonPath, packageJsonSchema).map(
356
+ (packageJson2) => {
357
+ const key = type === "dev" ? "devDependencies" : "dependencies";
358
+ return packageJson2[key];
359
+ }
360
+ );
361
+ },
362
+ getRootRequiredScripts: () => (/* @__PURE__ */ new Map()).set("code-review", "eslint ."),
363
+ getScripts: (cwd2 = process3.cwd()) => {
364
+ const packageJsonPath = join2(cwd2, "package.json");
365
+ return readFileAndDecode(packageJsonPath, packageJsonSchema).map(
366
+ ({ scripts }) => scripts
367
+ );
368
+ },
369
+ readPackageJson: (cwd2 = process3.cwd()) => {
370
+ const packageJsonPath = join2(cwd2, "package.json");
371
+ return readFileAndDecode(packageJsonPath, packageJsonSchema);
372
+ }
373
+ });
374
+
375
+ // src/adapters/node/repository.ts
376
+ import { join as join3 } from "path";
377
+ var findRepositoryRoot = (dir = process.cwd()) => {
378
+ const gitPath = join3(dir, ".git");
379
+ return fileExists(gitPath).mapErr(
380
+ () => new Error(
381
+ "Could not find repository root. Make sure to have the repo initialized."
382
+ )
383
+ ).map(() => dir);
384
+ };
385
+ var makeRepositoryReader = () => ({
386
+ fileExists,
387
+ findRepositoryRoot,
388
+ readFile
389
+ });
390
+
391
+ // src/config.ts
392
+ var getConfig = (repositoryRoot2) => ({
393
+ minVersions: {
394
+ turbo: "2"
395
+ },
396
+ repository: {
397
+ root: repositoryRoot2
398
+ }
399
+ });
400
+
401
+ // src/index.ts
402
+ await configure({
403
+ loggers: [
404
+ { category: ["dx-cli"], lowestLevel: "info", sinks: ["console"] },
405
+ { category: ["json"], lowestLevel: "info", sinks: ["rawJson"] },
406
+ {
407
+ category: ["logtape", "meta"],
408
+ lowestLevel: "warning",
409
+ sinks: ["console"]
410
+ }
411
+ ],
412
+ sinks: {
413
+ console: getConsoleSink(),
414
+ rawJson(record) {
415
+ console.log(record.rawMessage);
416
+ }
417
+ }
418
+ });
419
+ var logger = getLogger4(["dx-cli"]);
420
+ var repositoryReader = makeRepositoryReader();
421
+ var packageJsonReader = makePackageJsonReader();
422
+ var validationReporter = makeValidationReporter();
423
+ var repoRoot = await repositoryReader.findRepositoryRoot(process.cwd());
424
+ if (repoRoot.isErr()) {
425
+ logger.error(
426
+ "Could not find repository root. Make sure to have the repo initialized."
427
+ );
428
+ process.exit(1);
429
+ }
430
+ var repositoryRoot = repoRoot.value;
431
+ var repoPackageJson = await packageJsonReader.readPackageJson(repositoryRoot);
432
+ if (repoPackageJson.isErr()) {
433
+ logger.error("Repository does not contain a package.json file");
434
+ process.exit(1);
435
+ }
436
+ var packageJson = repoPackageJson.value;
437
+ var deps = {
438
+ packageJson,
439
+ packageJsonReader,
440
+ repositoryReader,
441
+ validationReporter
442
+ };
443
+ var config = getConfig(repositoryRoot);
444
+ var program = makeCli(deps, config);
445
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@pagopa/dx-cli",
3
+ "version": "0.4.1",
4
+ "type": "module",
5
+ "description": "A CLI useful to manage DX tools.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/pagopa/dx.git",
9
+ "directory": "apps/cli"
10
+ },
11
+ "keywords": [
12
+ "DX",
13
+ "CLI"
14
+ ],
15
+ "files": [
16
+ "bin"
17
+ ],
18
+ "bin": {
19
+ "dx": "./bin/index.js"
20
+ },
21
+ "dependencies": {
22
+ "@logtape/logtape": "^1.0.0",
23
+ "commander": "^14.0.0",
24
+ "core-js": "^3.44.0",
25
+ "neverthrow": "^8.2.0",
26
+ "semver": "^7.7.2",
27
+ "zod": "^3.25.28"
28
+ },
29
+ "devDependencies": {
30
+ "@tsconfig/node22": "22.0.2",
31
+ "@types/node": "^22.16.2",
32
+ "@types/semver": "^7.7.0",
33
+ "@vitest/coverage-v8": "^3.2.4",
34
+ "eslint": "^9.31.0",
35
+ "prettier": "3.6.2",
36
+ "tsup": "^8.5.0",
37
+ "typescript": "~5.8.3",
38
+ "vitest": "^3.2.4",
39
+ "vitest-mock-extended": "^3.1.0",
40
+ "@pagopa/eslint-config": "^5.0.0"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.0.0"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "lint": "eslint --fix src",
48
+ "lint:check": "eslint src",
49
+ "format": "prettier --write .",
50
+ "format:check": "prettier --check .",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "vitest run",
53
+ "test:coverage": "vitest --coverage"
54
+ }
55
+ }