clonecheck-monorepo 0.1.0
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.
- package/.env.example +2 -0
- package/.gitattributes +1 -0
- package/.github/workflows/ci.yml +24 -0
- package/CONTRIBUTING.md +22 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/action/README.md +34 -0
- package/action/action.yml +50 -0
- package/clonecheck.config.json +5 -0
- package/docs/checks.md +44 -0
- package/docs/config.md +36 -0
- package/docs/scoring.md +20 -0
- package/eslint.config.js +29 -0
- package/examples/docker-compose-missing-env/.env.example +1 -0
- package/examples/docker-compose-missing-env/README.md +5 -0
- package/examples/docker-compose-missing-env/docker-compose.yml +12 -0
- package/examples/node-basic/.env.example +1 -0
- package/examples/node-basic/README.md +8 -0
- package/examples/node-basic/package.json +15 -0
- package/examples/node-basic/pnpm-lock.yaml +5 -0
- package/examples/node-basic/src-index.ts +1 -0
- package/examples/node-missing-env/.env.example +1 -0
- package/examples/node-missing-env/README.md +8 -0
- package/examples/node-missing-env/package.json +11 -0
- package/examples/node-missing-env/pnpm-lock.yaml +5 -0
- package/examples/node-missing-env/src/app.ts +14 -0
- package/examples/python-basic/.env.example +1 -0
- package/examples/python-basic/README.md +8 -0
- package/examples/python-basic/app.py +6 -0
- package/examples/python-basic/requirements.txt +1 -0
- package/package.json +40 -0
- package/packages/cli/LICENSE +21 -0
- package/packages/cli/README.md +15 -0
- package/packages/cli/package.json +55 -0
- package/packages/cli/src/index.ts +186 -0
- package/packages/cli/tsconfig.json +11 -0
- package/packages/core/LICENSE +21 -0
- package/packages/core/README.md +11 -0
- package/packages/core/package.json +51 -0
- package/packages/core/src/checks/ciPresence.ts +35 -0
- package/packages/core/src/checks/dockerComposeEnv.ts +61 -0
- package/packages/core/src/checks/envExample.ts +31 -0
- package/packages/core/src/checks/helpers.ts +54 -0
- package/packages/core/src/checks/index.ts +24 -0
- package/packages/core/src/checks/packageManagerConsistency.ts +65 -0
- package/packages/core/src/checks/portDocumentation.ts +62 -0
- package/packages/core/src/checks/projectFiles.ts +77 -0
- package/packages/core/src/checks/readmeCommands.ts +87 -0
- package/packages/core/src/checks/scriptsAvailability.ts +69 -0
- package/packages/core/src/config.ts +96 -0
- package/packages/core/src/detectors/dockerCompose.ts +62 -0
- package/packages/core/src/detectors/env.ts +189 -0
- package/packages/core/src/detectors/packageManager.ts +54 -0
- package/packages/core/src/detectors/ports.ts +99 -0
- package/packages/core/src/detectors/project.ts +50 -0
- package/packages/core/src/detectors/readme.ts +131 -0
- package/packages/core/src/index.ts +31 -0
- package/packages/core/src/reporters/index.ts +3 -0
- package/packages/core/src/reporters/json.ts +5 -0
- package/packages/core/src/reporters/labels.ts +40 -0
- package/packages/core/src/reporters/markdown.ts +63 -0
- package/packages/core/src/reporters/text.ts +108 -0
- package/packages/core/src/run.ts +111 -0
- package/packages/core/src/scoring.ts +60 -0
- package/packages/core/src/types.ts +85 -0
- package/packages/core/src/utils/files.ts +109 -0
- package/packages/core/tests/config.test.ts +53 -0
- package/packages/core/tests/detectors.test.ts +127 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/.env.example +1 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/README.md +5 -0
- package/packages/core/tests/fixtures/docker-compose-missing-env/docker-compose.yml +6 -0
- package/packages/core/tests/fixtures/env-missing/.env.example +1 -0
- package/packages/core/tests/fixtures/env-missing/README.md +6 -0
- package/packages/core/tests/fixtures/env-missing/package-lock.json +4 -0
- package/packages/core/tests/fixtures/env-missing/package.json +8 -0
- package/packages/core/tests/fixtures/env-missing/src.py +4 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/.env.example +1 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/README.md +8 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/package.json +8 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/pnpm-lock.yaml +1 -0
- package/packages/core/tests/fixtures/node-pnpm-readme-npm/src.ts +3 -0
- package/packages/core/tests/reporters.test.ts +43 -0
- package/packages/core/tests/run.test.ts +37 -0
- package/packages/core/tests/scoring.test.ts +33 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/npm/LICENSE +21 -0
- package/packages/npm/README.md +17 -0
- package/packages/npm/package.json +54 -0
- package/packages/npm/src/index.ts +2 -0
- package/packages/npm/tsconfig.json +12 -0
- package/pnpm-workspace.yaml +4 -0
- package/prettier.config.js +5 -0
- package/scripts/build-package.mjs +30 -0
- package/tsconfig.base.json +17 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ClonecheckCheck, ClonecheckIssue } from "../types.js";
|
|
2
|
+
import { detectRepoPorts } from "../detectors/ports.js";
|
|
3
|
+
import { createIssue, createResult } from "./helpers.js";
|
|
4
|
+
|
|
5
|
+
export const portDocumentationCheck: ClonecheckCheck = {
|
|
6
|
+
id: "port-documentation",
|
|
7
|
+
title: "Port documentation",
|
|
8
|
+
description: "Checks whether README localhost ports match likely ports used by the app.",
|
|
9
|
+
async run(context) {
|
|
10
|
+
if (!context.readmeText) {
|
|
11
|
+
return createResult({
|
|
12
|
+
id: this.id,
|
|
13
|
+
title: this.title,
|
|
14
|
+
status: "skip"
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { codePorts, readmePorts } = await detectRepoPorts(context.repoPath, context.files, context.readmeText);
|
|
19
|
+
const uniqueCodePorts = Array.from(new Set(codePorts.map((entry) => entry.port))).sort((a, b) => a - b);
|
|
20
|
+
const issues: ClonecheckIssue[] = [];
|
|
21
|
+
|
|
22
|
+
if (uniqueCodePorts.length === 0) {
|
|
23
|
+
return createResult({
|
|
24
|
+
id: this.id,
|
|
25
|
+
title: this.title,
|
|
26
|
+
issues
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (readmePorts.length === 0) {
|
|
31
|
+
const first = codePorts[0];
|
|
32
|
+
issues.push(
|
|
33
|
+
createIssue({
|
|
34
|
+
id: "port-documentation.missing-readme-port",
|
|
35
|
+
title: "README does not mention the app port",
|
|
36
|
+
message: `The app appears to use port ${uniqueCodePorts.join(", ")}, but README does not mention a localhost port.`,
|
|
37
|
+
severity: "warning",
|
|
38
|
+
file: first?.file,
|
|
39
|
+
suggestion: `Mention http://localhost:${uniqueCodePorts[0]} in the README quickstart.`
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
} else if (!uniqueCodePorts.some((port) => readmePorts.includes(port))) {
|
|
43
|
+
const first = codePorts[0];
|
|
44
|
+
issues.push(
|
|
45
|
+
createIssue({
|
|
46
|
+
id: "port-documentation.mismatch",
|
|
47
|
+
title: "README mentions a different port",
|
|
48
|
+
message: `The app appears to use port ${uniqueCodePorts.join(", ")}, but README mentions ${readmePorts.join(", ")}.`,
|
|
49
|
+
severity: "warning",
|
|
50
|
+
file: first?.file,
|
|
51
|
+
suggestion: `Update README quickstart port from ${readmePorts[0]} to ${uniqueCodePorts[0]}.`
|
|
52
|
+
})
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return createResult({
|
|
57
|
+
id: this.id,
|
|
58
|
+
title: this.title,
|
|
59
|
+
issues
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ClonecheckCheck } from "../types.js";
|
|
2
|
+
import { findReadmeFile } from "../detectors/readme.js";
|
|
3
|
+
import { findAnyCaseInsensitive } from "../utils/files.js";
|
|
4
|
+
import { createIssue, createResult } from "./helpers.js";
|
|
5
|
+
|
|
6
|
+
export const projectFilesCheck: ClonecheckCheck = {
|
|
7
|
+
id: "project-files",
|
|
8
|
+
title: "Project files",
|
|
9
|
+
description: "Checks for README, contributing, license, and gitignore files.",
|
|
10
|
+
async run(context) {
|
|
11
|
+
const issues = [];
|
|
12
|
+
|
|
13
|
+
if (!findReadmeFile(context.files)) {
|
|
14
|
+
issues.push(
|
|
15
|
+
createIssue({
|
|
16
|
+
id: "project-files.readme",
|
|
17
|
+
title: "README is missing",
|
|
18
|
+
message: "Repository does not include a README.md file.",
|
|
19
|
+
severity: "error",
|
|
20
|
+
suggestion: "Add a README with setup, run, and contribution instructions."
|
|
21
|
+
})
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!context.files.includes(".gitignore")) {
|
|
26
|
+
issues.push(
|
|
27
|
+
createIssue({
|
|
28
|
+
id: "project-files.gitignore",
|
|
29
|
+
title: ".gitignore is missing",
|
|
30
|
+
message: "Repository does not include a .gitignore file.",
|
|
31
|
+
severity: "warning",
|
|
32
|
+
suggestion: "Add a .gitignore for generated files and local secrets."
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (context.config.checks.contributing !== false) {
|
|
38
|
+
const contributing = findAnyCaseInsensitive(context.files, [
|
|
39
|
+
"CONTRIBUTING.md",
|
|
40
|
+
".github/CONTRIBUTING.md",
|
|
41
|
+
"docs/CONTRIBUTING.md"
|
|
42
|
+
]);
|
|
43
|
+
if (!contributing) {
|
|
44
|
+
issues.push(
|
|
45
|
+
createIssue({
|
|
46
|
+
id: "project-files.contributing",
|
|
47
|
+
title: "Contributing guide is missing",
|
|
48
|
+
message: "Repository does not include a CONTRIBUTING.md file.",
|
|
49
|
+
severity: "info",
|
|
50
|
+
suggestion: "Add CONTRIBUTING.md with local setup and pull request guidance."
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (context.config.checks.license !== false) {
|
|
57
|
+
const license = findAnyCaseInsensitive(context.files, ["LICENSE", "LICENSE.md", "COPYING"]);
|
|
58
|
+
if (!license) {
|
|
59
|
+
issues.push(
|
|
60
|
+
createIssue({
|
|
61
|
+
id: "project-files.license",
|
|
62
|
+
title: "License is missing",
|
|
63
|
+
message: "Repository does not include a license file.",
|
|
64
|
+
severity: "info",
|
|
65
|
+
suggestion: "Add a LICENSE file so contributors know how the project can be used."
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return createResult({
|
|
72
|
+
id: this.id,
|
|
73
|
+
title: this.title,
|
|
74
|
+
issues
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ClonecheckCheck } from "../types.js";
|
|
2
|
+
import {
|
|
3
|
+
extractReadmeCommands,
|
|
4
|
+
isPackageInstallCommand,
|
|
5
|
+
readmeMentionsRunCommand,
|
|
6
|
+
readmeMentionsSetupCommand
|
|
7
|
+
} from "../detectors/readme.js";
|
|
8
|
+
import { asPackageJson, createIssue, createResult } from "./helpers.js";
|
|
9
|
+
|
|
10
|
+
export const readmeCommandsCheck: ClonecheckCheck = {
|
|
11
|
+
id: "readme-commands",
|
|
12
|
+
title: "README commands",
|
|
13
|
+
description: "Checks whether README setup and run commands are present and aligned with the repo.",
|
|
14
|
+
async run(context) {
|
|
15
|
+
if (!context.readmeText) {
|
|
16
|
+
return createResult({
|
|
17
|
+
id: this.id,
|
|
18
|
+
title: this.title,
|
|
19
|
+
status: "skip"
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const commands = extractReadmeCommands(context.readmeText);
|
|
24
|
+
const issues = [];
|
|
25
|
+
|
|
26
|
+
if (!readmeMentionsSetupCommand(commands)) {
|
|
27
|
+
issues.push(
|
|
28
|
+
createIssue({
|
|
29
|
+
id: "readme-commands.no-setup-command",
|
|
30
|
+
title: "README has no setup command",
|
|
31
|
+
message: "README does not include an obvious install or docker compose setup command.",
|
|
32
|
+
severity: "warning",
|
|
33
|
+
file: "README.md",
|
|
34
|
+
suggestion: "Add a quickstart command such as pnpm install or docker compose up."
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (context.detectedPackageManager) {
|
|
40
|
+
const mismatchedManagers = Array.from(
|
|
41
|
+
new Set(
|
|
42
|
+
commands
|
|
43
|
+
.filter((command) => !(command.kind === "install" && isPackageInstallCommand(command.command)))
|
|
44
|
+
.map((command) => command.packageManager)
|
|
45
|
+
.filter((manager) => manager && manager !== context.detectedPackageManager)
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
for (const manager of mismatchedManagers) {
|
|
50
|
+
if (!manager) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
issues.push(
|
|
54
|
+
createIssue({
|
|
55
|
+
id: `readme-commands.${manager}`,
|
|
56
|
+
title: "README command conflicts with lockfile",
|
|
57
|
+
message: `README uses ${manager} commands, but the detected package manager is ${context.detectedPackageManager}.`,
|
|
58
|
+
severity: "warning",
|
|
59
|
+
file: "README.md",
|
|
60
|
+
suggestion: `Update README commands to use ${context.detectedPackageManager}.`
|
|
61
|
+
})
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scripts = asPackageJson(context.packageJson)?.scripts ?? {};
|
|
67
|
+
const hasRunScript = Boolean(scripts.dev || scripts.start);
|
|
68
|
+
if (hasRunScript && !readmeMentionsRunCommand(commands)) {
|
|
69
|
+
issues.push(
|
|
70
|
+
createIssue({
|
|
71
|
+
id: "readme-commands.no-run-command",
|
|
72
|
+
title: "README does not show how to run the app",
|
|
73
|
+
message: "package.json has a dev or start script, but README does not include a matching run command.",
|
|
74
|
+
severity: "warning",
|
|
75
|
+
file: "README.md",
|
|
76
|
+
suggestion: "Add a README quickstart step for running the app locally."
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return createResult({
|
|
82
|
+
id: this.id,
|
|
83
|
+
title: this.title,
|
|
84
|
+
issues
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ClonecheckCheck } from "../types.js";
|
|
2
|
+
import { asPackageJson, createIssue, createResult } from "./helpers.js";
|
|
3
|
+
|
|
4
|
+
export const scriptsAvailabilityCheck: ClonecheckCheck = {
|
|
5
|
+
id: "scripts-availability",
|
|
6
|
+
title: "Scripts availability",
|
|
7
|
+
description: "Checks whether Node projects expose obvious scripts for running, testing, and building.",
|
|
8
|
+
async run(context) {
|
|
9
|
+
if (!context.detectedProjectTypes.includes("node")) {
|
|
10
|
+
return createResult({
|
|
11
|
+
id: this.id,
|
|
12
|
+
title: this.title,
|
|
13
|
+
status: "skip"
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const packageJson = asPackageJson(context.packageJson);
|
|
18
|
+
const scripts = packageJson?.scripts ?? {};
|
|
19
|
+
const hasDev = Boolean(scripts.dev);
|
|
20
|
+
const hasStart = Boolean(scripts.start);
|
|
21
|
+
const hasTest = Boolean(scripts.test);
|
|
22
|
+
const hasBuild = Boolean(scripts.build);
|
|
23
|
+
const hasAnyRunScript = hasDev || hasStart || hasTest || hasBuild;
|
|
24
|
+
const issues = [];
|
|
25
|
+
|
|
26
|
+
if (!hasAnyRunScript) {
|
|
27
|
+
issues.push(
|
|
28
|
+
createIssue({
|
|
29
|
+
id: "scripts-availability.no-run-scripts",
|
|
30
|
+
title: "No runnable package scripts",
|
|
31
|
+
message: "package.json does not define dev, start, test, or build scripts.",
|
|
32
|
+
severity: "error",
|
|
33
|
+
file: "package.json",
|
|
34
|
+
suggestion: "Add at least one obvious script such as dev, start, test, or build."
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
} else if (!hasDev && !hasStart && !hasBuild) {
|
|
38
|
+
issues.push(
|
|
39
|
+
createIssue({
|
|
40
|
+
id: "scripts-availability.no-app-run-script",
|
|
41
|
+
title: "No app run script",
|
|
42
|
+
message: "package.json has scripts, but no dev, start, or build script tells contributors how to run the app.",
|
|
43
|
+
severity: "warning",
|
|
44
|
+
file: "package.json",
|
|
45
|
+
suggestion: "Add a dev or start script for the main local workflow."
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!hasTest) {
|
|
51
|
+
issues.push(
|
|
52
|
+
createIssue({
|
|
53
|
+
id: "scripts-availability.no-test-script",
|
|
54
|
+
title: "No test script",
|
|
55
|
+
message: "package.json does not define a test script.",
|
|
56
|
+
severity: "warning",
|
|
57
|
+
file: "package.json",
|
|
58
|
+
suggestion: "Add a test script, even if it only runs a small smoke test at first."
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return createResult({
|
|
64
|
+
id: this.id,
|
|
65
|
+
title: this.title,
|
|
66
|
+
issues
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { DEFAULT_CONFIG, type ClonecheckConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
export const CONFIG_FILENAMES = [
|
|
7
|
+
"clonecheck.config.json",
|
|
8
|
+
".clonecheckrc",
|
|
9
|
+
".clonecheckrc.json"
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
const configSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
ignore: z.array(z.string()).default([]),
|
|
15
|
+
ignoreEnvVars: z.array(z.string()).default([]),
|
|
16
|
+
checks: z.record(z.boolean()).default({})
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
|
|
20
|
+
export class ClonecheckConfigError extends Error {
|
|
21
|
+
constructor(message: string) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "ClonecheckConfigError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LoadedConfig {
|
|
28
|
+
config: ClonecheckConfig;
|
|
29
|
+
configPath?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
33
|
+
try {
|
|
34
|
+
await access(filePath);
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function findConfigPath(repoPath: string, configPath?: string): Promise<string | undefined> {
|
|
42
|
+
if (configPath) {
|
|
43
|
+
const resolved = path.isAbsolute(configPath) ? configPath : path.join(repoPath, configPath);
|
|
44
|
+
if (!(await fileExists(resolved))) {
|
|
45
|
+
throw new ClonecheckConfigError(`Config file was not found: ${resolved}`);
|
|
46
|
+
}
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const filename of CONFIG_FILENAMES) {
|
|
51
|
+
const candidate = path.join(repoPath, filename);
|
|
52
|
+
if (await fileExists(candidate)) {
|
|
53
|
+
return candidate;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function loadConfig(repoPath: string, configPath?: string): Promise<LoadedConfig> {
|
|
61
|
+
const foundPath = await findConfigPath(repoPath, configPath);
|
|
62
|
+
if (!foundPath) {
|
|
63
|
+
return { config: { ...DEFAULT_CONFIG } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let parsed: unknown;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(await readFile(foundPath, "utf8"));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
71
|
+
throw new ClonecheckConfigError(`Could not parse clonecheck config at ${foundPath}: ${detail}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = configSchema.safeParse(parsed);
|
|
75
|
+
if (!result.success) {
|
|
76
|
+
const issues = result.error.issues.map((issue) => {
|
|
77
|
+
const key = issue.path.length > 0 ? issue.path.join(".") : "config";
|
|
78
|
+
return `${key}: ${issue.message}`;
|
|
79
|
+
});
|
|
80
|
+
throw new ClonecheckConfigError(
|
|
81
|
+
`Invalid clonecheck config at ${foundPath}:\n${issues.map((issue) => `- ${issue}`).join("\n")}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { config: result.data, configPath: foundPath };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function initConfig(repoPath: string, force = false): Promise<{ path: string; created: boolean }> {
|
|
89
|
+
const target = path.join(repoPath, "clonecheck.config.json");
|
|
90
|
+
if (!force && (await fileExists(target))) {
|
|
91
|
+
return { path: target, created: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await writeFile(`${target}`, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf8");
|
|
95
|
+
return { path: target, created: true };
|
|
96
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
import { readTextFile } from "../utils/files.js";
|
|
3
|
+
|
|
4
|
+
export const COMPOSE_FILES = [
|
|
5
|
+
"docker-compose.yml",
|
|
6
|
+
"docker-compose.yaml",
|
|
7
|
+
"compose.yml",
|
|
8
|
+
"compose.yaml"
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export interface DockerComposeEnvReference {
|
|
12
|
+
name: string;
|
|
13
|
+
file: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DockerComposeScanResult {
|
|
17
|
+
refs: DockerComposeEnvReference[];
|
|
18
|
+
parseErrors: Array<{ file: string; message: string }>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function extractDockerComposeEnvVars(text: string, file: string): DockerComposeEnvReference[] {
|
|
22
|
+
const refs: DockerComposeEnvReference[] = [];
|
|
23
|
+
const regex = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}/g;
|
|
24
|
+
let match: RegExpExecArray | null;
|
|
25
|
+
|
|
26
|
+
while ((match = regex.exec(text)) !== null) {
|
|
27
|
+
const name = match[1];
|
|
28
|
+
if (name) {
|
|
29
|
+
refs.push({ name, file });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return refs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function detectDockerComposeEnvVars(
|
|
37
|
+
repoPath: string,
|
|
38
|
+
files: string[]
|
|
39
|
+
): Promise<DockerComposeScanResult> {
|
|
40
|
+
const refs: DockerComposeEnvReference[] = [];
|
|
41
|
+
const parseErrors: Array<{ file: string; message: string }> = [];
|
|
42
|
+
|
|
43
|
+
for (const file of files.filter((candidate) => COMPOSE_FILES.includes(candidate))) {
|
|
44
|
+
const text = await readTextFile(repoPath, file);
|
|
45
|
+
if (!text) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
yaml.load(text);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
parseErrors.push({
|
|
53
|
+
file,
|
|
54
|
+
message: error instanceof Error ? error.message : String(error)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
refs.push(...extractDockerComposeEnvVars(text, file));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { refs, parseErrors };
|
|
62
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ClonecheckConfig } from "../types.js";
|
|
4
|
+
import { absolutePath, isLikelyTextFile, lineNumberFromIndex, readTextFile } from "../utils/files.js";
|
|
5
|
+
|
|
6
|
+
export const ENV_EXAMPLE_FILES = [".env.example", ".env.sample", ".env.template", "example.env"];
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_IGNORE_ENV_VARS = new Set(["NODE_ENV", "PATH", "HOME", "PWD", "CI", "PORT"]);
|
|
9
|
+
|
|
10
|
+
const ENV_SOURCE_EXTENSIONS = new Set([
|
|
11
|
+
".cjs",
|
|
12
|
+
".go",
|
|
13
|
+
".js",
|
|
14
|
+
".jsx",
|
|
15
|
+
".mjs",
|
|
16
|
+
".py",
|
|
17
|
+
".rs",
|
|
18
|
+
".ts",
|
|
19
|
+
".tsx"
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
export interface EnvVarUsage {
|
|
23
|
+
name: string;
|
|
24
|
+
file: string;
|
|
25
|
+
line: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ENV_PATTERNS = [
|
|
29
|
+
/process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
30
|
+
/process\.env\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g,
|
|
31
|
+
/import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
|
|
32
|
+
/os\.environ\[['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\]/g,
|
|
33
|
+
/os\.getenv\(['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\)/g,
|
|
34
|
+
/os\.Getenv\(['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\)/g,
|
|
35
|
+
/std::env::var\(['"`]([A-Za-z_][A-Za-z0-9_]*)['"`]\)/g
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function isEnvSourceFile(file: string): boolean {
|
|
39
|
+
if (!isLikelyTextFile(file)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return ENV_SOURCE_EXTENSIONS.has(path.extname(file));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseEnvExampleVars(text: string | undefined): Set<string> {
|
|
47
|
+
const vars = new Set<string>();
|
|
48
|
+
if (!text) {
|
|
49
|
+
return vars;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const regex = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm;
|
|
53
|
+
let match: RegExpExecArray | null;
|
|
54
|
+
while ((match = regex.exec(text)) !== null) {
|
|
55
|
+
const name = match[1];
|
|
56
|
+
if (name) {
|
|
57
|
+
vars.add(name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return vars;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function extractEnvVarUsages(text: string, file: string): EnvVarUsage[] {
|
|
65
|
+
const usages: EnvVarUsage[] = [];
|
|
66
|
+
|
|
67
|
+
for (const pattern of ENV_PATTERNS) {
|
|
68
|
+
pattern.lastIndex = 0;
|
|
69
|
+
let match: RegExpExecArray | null;
|
|
70
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
71
|
+
const name = match[1];
|
|
72
|
+
if (!name) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
usages.push({
|
|
77
|
+
name,
|
|
78
|
+
file,
|
|
79
|
+
line: lineNumberFromIndex(text, match.index)
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return usages;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function detectEnvVarUsages(
|
|
88
|
+
repoPath: string,
|
|
89
|
+
files: string[],
|
|
90
|
+
config: ClonecheckConfig
|
|
91
|
+
): Promise<EnvVarUsage[]> {
|
|
92
|
+
const ignored = new Set([...DEFAULT_IGNORE_ENV_VARS, ...config.ignoreEnvVars]);
|
|
93
|
+
const usages: EnvVarUsage[] = [];
|
|
94
|
+
|
|
95
|
+
for (const file of files) {
|
|
96
|
+
if (!isEnvSourceFile(file)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const text = await readTextFile(repoPath, file);
|
|
101
|
+
if (!text) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (const usage of extractEnvVarUsages(text, file)) {
|
|
106
|
+
if (!ignored.has(usage.name)) {
|
|
107
|
+
usages.push(usage);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return usages;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function loadEnvExampleFiles(
|
|
116
|
+
repoPath: string,
|
|
117
|
+
files: string[]
|
|
118
|
+
): Promise<{ text?: string; vars: Set<string>; files: string[] }> {
|
|
119
|
+
const foundFiles = ENV_EXAMPLE_FILES.filter((file) => files.includes(file));
|
|
120
|
+
const vars = new Set<string>();
|
|
121
|
+
let firstText: string | undefined;
|
|
122
|
+
|
|
123
|
+
for (const file of foundFiles) {
|
|
124
|
+
const text = await readTextFile(repoPath, file);
|
|
125
|
+
if (text === undefined) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
firstText ??= text;
|
|
129
|
+
for (const name of parseEnvExampleVars(text)) {
|
|
130
|
+
vars.add(name);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { text: firstText, vars, files: foundFiles };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function findMissingEnvVars(usages: EnvVarUsage[], documentedVars: Set<string>): string[] {
|
|
138
|
+
const used = new Set(usages.map((usage) => usage.name));
|
|
139
|
+
return Array.from(used)
|
|
140
|
+
.filter((name) => !documentedVars.has(name))
|
|
141
|
+
.sort((a, b) => a.localeCompare(b));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface GenerateEnvExampleOptions {
|
|
145
|
+
repoPath: string;
|
|
146
|
+
files: string[];
|
|
147
|
+
config: ClonecheckConfig;
|
|
148
|
+
write?: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface GenerateEnvExampleResult {
|
|
152
|
+
content: string;
|
|
153
|
+
missingVars: string[];
|
|
154
|
+
writtenPath?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function appendEnvLines(existing: string, missingVars: string[]): string {
|
|
158
|
+
const prefix = existing.length > 0 && !existing.endsWith("\n") ? `${existing}\n` : existing;
|
|
159
|
+
const needsSpacer = prefix.trim().length > 0 && missingVars.length > 0 ? "\n" : "";
|
|
160
|
+
return `${prefix}${needsSpacer}${missingVars.map((name) => `${name}=`).join("\n")}${missingVars.length > 0 ? "\n" : ""}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function generateEnvExample(
|
|
164
|
+
options: GenerateEnvExampleOptions
|
|
165
|
+
): Promise<GenerateEnvExampleResult> {
|
|
166
|
+
const envExamples = await loadEnvExampleFiles(options.repoPath, options.files);
|
|
167
|
+
const usages = await detectEnvVarUsages(options.repoPath, options.files, options.config);
|
|
168
|
+
const missingVars = findMissingEnvVars(usages, envExamples.vars);
|
|
169
|
+
const target = absolutePath(options.repoPath, ".env.example");
|
|
170
|
+
|
|
171
|
+
let existing = "";
|
|
172
|
+
try {
|
|
173
|
+
existing = await readFile(target, "utf8");
|
|
174
|
+
} catch {
|
|
175
|
+
existing = "";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const content = appendEnvLines(existing, missingVars);
|
|
179
|
+
|
|
180
|
+
if (options.write) {
|
|
181
|
+
await writeFile(target, content, "utf8");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
content,
|
|
186
|
+
missingVars,
|
|
187
|
+
writtenPath: options.write ? target : undefined
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PackageJson, PackageManager } from "../types.js";
|
|
2
|
+
|
|
3
|
+
const LOCKFILE_MANAGERS: Array<{ file: string; manager: PackageManager }> = [
|
|
4
|
+
{ file: "pnpm-lock.yaml", manager: "pnpm" },
|
|
5
|
+
{ file: "yarn.lock", manager: "yarn" },
|
|
6
|
+
{ file: "package-lock.json", manager: "npm" },
|
|
7
|
+
{ file: "bun.lockb", manager: "bun" },
|
|
8
|
+
{ file: "bun.lock", manager: "bun" }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export interface PackageManagerDetection {
|
|
12
|
+
manager?: PackageManager;
|
|
13
|
+
lockfiles: string[];
|
|
14
|
+
managers: PackageManager[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function detectPackageManager(
|
|
18
|
+
files: string[],
|
|
19
|
+
packageJson?: PackageJson
|
|
20
|
+
): PackageManagerDetection {
|
|
21
|
+
const matches = LOCKFILE_MANAGERS.filter(({ file }) => files.includes(file));
|
|
22
|
+
const lockManagers = Array.from(new Set(matches.map((match) => match.manager)));
|
|
23
|
+
|
|
24
|
+
if (lockManagers.length > 0) {
|
|
25
|
+
return {
|
|
26
|
+
manager: lockManagers[0],
|
|
27
|
+
lockfiles: matches.map((match) => match.file),
|
|
28
|
+
managers: lockManagers
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const packageManager = packageJson?.packageManager?.split("@")[0];
|
|
33
|
+
if (
|
|
34
|
+
packageManager === "pnpm" ||
|
|
35
|
+
packageManager === "npm" ||
|
|
36
|
+
packageManager === "yarn" ||
|
|
37
|
+
packageManager === "bun"
|
|
38
|
+
) {
|
|
39
|
+
return {
|
|
40
|
+
manager: packageManager,
|
|
41
|
+
lockfiles: [],
|
|
42
|
+
managers: [packageManager]
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
lockfiles: [],
|
|
48
|
+
managers: []
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isPackageManager(value: string): value is PackageManager {
|
|
53
|
+
return value === "pnpm" || value === "npm" || value === "yarn" || value === "bun";
|
|
54
|
+
}
|