predeploy-check 1.0.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/README.md +74 -0
- package/bin/cli.js +48 -0
- package/checks/01-python-render.js +123 -0
- package/checks/02-eslint-vercel.js +136 -0
- package/checks/03-case-sensitivity.js +228 -0
- package/checks/04-missing-engines.js +54 -0
- package/checks/05-env-vars.js +215 -0
- package/checks/06-render-start.js +132 -0
- package/index.js +130 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# predeploy-check
|
|
2
|
+
|
|
3
|
+
> Catch deployment failures **before** you push. Scans your project for known Render & Vercel pitfalls.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx predeploy-check
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No install required — just run it in your project directory.
|
|
10
|
+
|
|
11
|
+
## What it checks
|
|
12
|
+
|
|
13
|
+
| # | Check | Targets | Status |
|
|
14
|
+
|---|-------|---------|--------|
|
|
15
|
+
| 1 | **Python + Render** | Rust-compiled deps (pydantic, fastapi, orjson…) on Python ≥ 3.13 | ⚠️ Warn |
|
|
16
|
+
| 2 | **ESLint + Vercel** | Mismatched eslint / eslint-config-next versions; deprecated `ignoreDuringBuilds` on Next 16+ | ❌ Fail / ⚠️ Warn |
|
|
17
|
+
| 3 | **Case Sensitivity** | Import paths that differ in casing from actual filenames (breaks on Linux) | ⚠️ Warn |
|
|
18
|
+
| 4 | **Missing Engines** | No `"engines"` field in package.json | ⚠️ Warn |
|
|
19
|
+
| 5 | **Env Var Check** | `process.env.X` references not declared in `.env` / `.env.example` | ⚠️ Warn |
|
|
20
|
+
| 6 | **Render Start Cmd** | No Procfile, no `"start"` script, no render.yaml start command | ❌ Fail |
|
|
21
|
+
|
|
22
|
+
## Output
|
|
23
|
+
|
|
24
|
+
Clean, colored terminal output with:
|
|
25
|
+
- ✅ / ⚠️ / ❌ per check
|
|
26
|
+
- File and line context
|
|
27
|
+
- One-line suggested fix
|
|
28
|
+
|
|
29
|
+
Exit code `1` if any ❌ failures, `0` otherwise.
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Scan current directory
|
|
35
|
+
npx predeploy-check
|
|
36
|
+
|
|
37
|
+
# Scan a specific project
|
|
38
|
+
npx predeploy-check ./my-project
|
|
39
|
+
|
|
40
|
+
# Show help
|
|
41
|
+
npx predeploy-check --help
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Adding custom checks
|
|
45
|
+
|
|
46
|
+
Create a new file in the `checks/` folder:
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// checks/07-my-check.js
|
|
50
|
+
'use strict';
|
|
51
|
+
|
|
52
|
+
const name = 'My Custom Check';
|
|
53
|
+
|
|
54
|
+
async function run(projectRoot) {
|
|
55
|
+
// Your check logic here
|
|
56
|
+
return {
|
|
57
|
+
status: 'pass', // 'pass' | 'warn' | 'fail' | 'skip'
|
|
58
|
+
message: 'Everything looks good',
|
|
59
|
+
fix: 'Suggested fix if status is warn or fail',
|
|
60
|
+
details: [
|
|
61
|
+
{ file: 'some-file.js', line: 42, message: 'Detail about the issue' }
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = { name, run };
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Checks are loaded alphabetically, so prefix with a number to control order.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
74
|
+
"# predeploy_check"
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { runAllChecks } = require('../index');
|
|
7
|
+
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
11
|
+
console.log(`
|
|
12
|
+
predeploy-check — catch deployment failures before they happen
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
npx predeploy-check [directory]
|
|
16
|
+
|
|
17
|
+
Arguments:
|
|
18
|
+
directory Path to the project to scan (defaults to current directory)
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-h, --help Show this help message
|
|
22
|
+
-v, --version Show version number
|
|
23
|
+
|
|
24
|
+
Checks:
|
|
25
|
+
1. Python + Render: Rust-compiled deps on unsupported Python versions
|
|
26
|
+
2. ESLint + Vercel: Peer-dependency mismatches in Next.js projects
|
|
27
|
+
3. Case Sensitivity: Import paths that differ from actual filenames
|
|
28
|
+
4. Missing Engines: No "engines" field in package.json
|
|
29
|
+
5. Env Var Check: process.env references missing from .env files
|
|
30
|
+
6. Render Start Cmd: Missing start command for Render deployments
|
|
31
|
+
`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
36
|
+
const pkg = require('../package.json');
|
|
37
|
+
console.log(pkg.version);
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const projectRoot = path.resolve(args[0] || process.cwd());
|
|
42
|
+
|
|
43
|
+
runAllChecks(projectRoot).then((exitCode) => {
|
|
44
|
+
process.exit(exitCode);
|
|
45
|
+
}).catch((err) => {
|
|
46
|
+
console.error('Fatal error:', err.message);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'Python + Render: Rust-compiled dependencies';
|
|
7
|
+
|
|
8
|
+
// Packages known to have Rust-compiled components that may lack
|
|
9
|
+
// prebuilt wheels for bleeding-edge Python versions.
|
|
10
|
+
const RUST_DEP_PACKAGES = [
|
|
11
|
+
'pydantic',
|
|
12
|
+
'pydantic-core',
|
|
13
|
+
'fastapi',
|
|
14
|
+
'orjson',
|
|
15
|
+
'cryptography',
|
|
16
|
+
'tiktoken',
|
|
17
|
+
'tokenizers',
|
|
18
|
+
'safetensors',
|
|
19
|
+
'polars',
|
|
20
|
+
'ruff',
|
|
21
|
+
'rpds-py',
|
|
22
|
+
'jiter',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a Python version string like "python-3.13.2" or "3.13" into
|
|
27
|
+
* { major, minor } or null if unparseable.
|
|
28
|
+
*/
|
|
29
|
+
function parsePythonVersion(raw) {
|
|
30
|
+
const match = raw.match(/(\d+)\.(\d+)/);
|
|
31
|
+
if (!match) return null;
|
|
32
|
+
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse requirements.txt and return an array of lowercase package names.
|
|
37
|
+
*/
|
|
38
|
+
function parseRequirements(content) {
|
|
39
|
+
return content
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line && !line.startsWith('#') && !line.startsWith('-'))
|
|
43
|
+
.map((line) => {
|
|
44
|
+
// Strip version specifiers, extras, etc.
|
|
45
|
+
const name = line.split(/[>=<!~\[;@\s]/)[0];
|
|
46
|
+
return name.toLowerCase().replace(/_/g, '-');
|
|
47
|
+
})
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function run(projectRoot) {
|
|
52
|
+
const runtimePath = path.join(projectRoot, 'runtime.txt');
|
|
53
|
+
const requirementsPath = path.join(projectRoot, 'requirements.txt');
|
|
54
|
+
|
|
55
|
+
// Check if this is a Python project at all
|
|
56
|
+
if (!fs.existsSync(runtimePath) && !fs.existsSync(requirementsPath)) {
|
|
57
|
+
return {
|
|
58
|
+
status: 'skip',
|
|
59
|
+
message: `${name} — no Python project detected`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// If there's no runtime.txt, we can't check the version
|
|
64
|
+
if (!fs.existsSync(runtimePath)) {
|
|
65
|
+
return {
|
|
66
|
+
status: 'skip',
|
|
67
|
+
message: `${name} — no runtime.txt found, skipping version check`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const runtimeContent = fs.readFileSync(runtimePath, 'utf-8').trim();
|
|
72
|
+
const version = parsePythonVersion(runtimeContent);
|
|
73
|
+
|
|
74
|
+
if (!version) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'warn',
|
|
77
|
+
message: `${name} — could not parse Python version from runtime.txt`,
|
|
78
|
+
fix: 'Ensure runtime.txt contains a valid version like "python-3.12.3"',
|
|
79
|
+
details: [{ file: 'runtime.txt', message: `Content: "${runtimeContent}"` }],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only flag Python >= 3.13
|
|
84
|
+
if (version.major < 3 || (version.major === 3 && version.minor < 13)) {
|
|
85
|
+
return {
|
|
86
|
+
status: 'pass',
|
|
87
|
+
message: `${name} — Python ${version.major}.${version.minor} is well-supported`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Now check requirements.txt for Rust-compiled deps
|
|
92
|
+
if (!fs.existsSync(requirementsPath)) {
|
|
93
|
+
return {
|
|
94
|
+
status: 'warn',
|
|
95
|
+
message: `${name} — Python ${version.major}.${version.minor} detected but no requirements.txt found`,
|
|
96
|
+
fix: 'Add a requirements.txt or pin Python to 3.12 in runtime.txt',
|
|
97
|
+
details: [{ file: 'runtime.txt' }],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const reqContent = fs.readFileSync(requirementsPath, 'utf-8');
|
|
102
|
+
const packages = parseRequirements(reqContent);
|
|
103
|
+
const flagged = packages.filter((pkg) => RUST_DEP_PACKAGES.includes(pkg));
|
|
104
|
+
|
|
105
|
+
if (flagged.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
status: 'pass',
|
|
108
|
+
message: `${name} — Python ${version.major}.${version.minor} with no known Rust-compiled deps`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
status: 'warn',
|
|
114
|
+
message: `${name} — Python ${version.major}.${version.minor} with Rust-compiled dependencies`,
|
|
115
|
+
fix: `Pin to Python 3.12 in runtime.txt ("python-3.12.7") or verify wheel availability for ${version.major}.${version.minor}`,
|
|
116
|
+
details: flagged.map((pkg) => ({
|
|
117
|
+
file: 'requirements.txt',
|
|
118
|
+
message: `"${pkg}" has Rust-compiled components — prebuilt wheels may not exist for Python ${version.major}.${version.minor}`,
|
|
119
|
+
})),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { name, run };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'ESLint + Vercel: Next.js peer-dependency conflicts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse a semver-like version string and return the major version number.
|
|
10
|
+
* Handles ranges like "^9.0.0", "~8.2.0", ">=9.0.0", "9.x", etc.
|
|
11
|
+
*/
|
|
12
|
+
function extractMajor(versionRange) {
|
|
13
|
+
if (!versionRange) return null;
|
|
14
|
+
const match = versionRange.match(/(\d+)/);
|
|
15
|
+
return match ? parseInt(match[1], 10) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalize a version range to a comparable string by stripping
|
|
20
|
+
* range prefixes like ^, ~, >=, etc.
|
|
21
|
+
*/
|
|
22
|
+
function normalizeRange(versionRange) {
|
|
23
|
+
if (!versionRange) return '';
|
|
24
|
+
return versionRange.replace(/^[\^~>=<\s]+/, '').trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function run(projectRoot) {
|
|
28
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
29
|
+
|
|
30
|
+
if (!fs.existsSync(pkgPath)) {
|
|
31
|
+
return {
|
|
32
|
+
status: 'skip',
|
|
33
|
+
message: `${name} — no package.json found`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let pkg;
|
|
38
|
+
try {
|
|
39
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
40
|
+
} catch {
|
|
41
|
+
return {
|
|
42
|
+
status: 'fail',
|
|
43
|
+
message: `${name} — could not parse package.json`,
|
|
44
|
+
fix: 'Fix JSON syntax errors in package.json',
|
|
45
|
+
details: [{ file: 'package.json' }],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
50
|
+
const results = [];
|
|
51
|
+
|
|
52
|
+
// Check if this is a Next.js project
|
|
53
|
+
const hasNext = !!allDeps['next'];
|
|
54
|
+
const hasEslint = !!allDeps['eslint'];
|
|
55
|
+
const hasEslintConfigNext = !!allDeps['eslint-config-next'];
|
|
56
|
+
|
|
57
|
+
if (!hasNext) {
|
|
58
|
+
return {
|
|
59
|
+
status: 'skip',
|
|
60
|
+
message: `${name} — not a Next.js project`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Check 1: ESLint / eslint-config-next version mismatch ──
|
|
65
|
+
|
|
66
|
+
if (hasEslint && hasEslintConfigNext) {
|
|
67
|
+
const eslintMajor = extractMajor(allDeps['eslint']);
|
|
68
|
+
const configNextMajor = extractMajor(allDeps['eslint-config-next']);
|
|
69
|
+
const nextMajor = extractMajor(allDeps['next']);
|
|
70
|
+
|
|
71
|
+
// eslint-config-next should typically match the Next.js major version
|
|
72
|
+
if (configNextMajor !== null && nextMajor !== null && configNextMajor !== nextMajor) {
|
|
73
|
+
results.push({
|
|
74
|
+
status: 'fail',
|
|
75
|
+
message: `${name} — eslint-config-next@${allDeps['eslint-config-next']} does not match next@${allDeps['next']}`,
|
|
76
|
+
fix: `Run: npm install eslint-config-next@${nextMajor} to align with your Next.js version`,
|
|
77
|
+
details: [
|
|
78
|
+
{ file: 'package.json', message: `eslint-config-next: ${allDeps['eslint-config-next']}` },
|
|
79
|
+
{ file: 'package.json', message: `next: ${allDeps['next']}` },
|
|
80
|
+
],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ESLint 9+ flat config vs older eslint-config-next
|
|
85
|
+
if (eslintMajor !== null && eslintMajor >= 9 && configNextMajor !== null && configNextMajor < 15) {
|
|
86
|
+
results.push({
|
|
87
|
+
status: 'warn',
|
|
88
|
+
message: `${name} — ESLint ${eslintMajor}.x may have peer-dep conflicts with eslint-config-next@${allDeps['eslint-config-next']}`,
|
|
89
|
+
fix: 'Upgrade eslint-config-next to a version compatible with ESLint 9+ flat config, or downgrade ESLint to 8.x',
|
|
90
|
+
details: [
|
|
91
|
+
{ file: 'package.json', message: `eslint: ${allDeps['eslint']}` },
|
|
92
|
+
{ file: 'package.json', message: `eslint-config-next: ${allDeps['eslint-config-next']}` },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Check 2: Deprecated eslint.ignoreDuringBuilds for Next 16+ ──
|
|
99
|
+
|
|
100
|
+
const nextMajor = extractMajor(allDeps['next']);
|
|
101
|
+
|
|
102
|
+
if (nextMajor !== null && nextMajor >= 16) {
|
|
103
|
+
// Check multiple possible config file names
|
|
104
|
+
const configNames = [
|
|
105
|
+
'next.config.js',
|
|
106
|
+
'next.config.mjs',
|
|
107
|
+
'next.config.ts',
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const configName of configNames) {
|
|
111
|
+
const configPath = path.join(projectRoot, configName);
|
|
112
|
+
if (fs.existsSync(configPath)) {
|
|
113
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
114
|
+
if (content.includes('ignoreDuringBuilds')) {
|
|
115
|
+
results.push({
|
|
116
|
+
status: 'warn',
|
|
117
|
+
message: `${name} — deprecated eslint.ignoreDuringBuilds in ${configName}`,
|
|
118
|
+
fix: 'Next.js 16+ removed built-in ESLint integration. Remove eslint.ignoreDuringBuilds from your config and use a separate ESLint step.',
|
|
119
|
+
details: [{ file: configName, message: 'Contains deprecated "ignoreDuringBuilds" setting' }],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (results.length === 0) {
|
|
127
|
+
return {
|
|
128
|
+
status: 'pass',
|
|
129
|
+
message: `${name} — ESLint configuration looks correct`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return results;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { name, run };
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'Case Sensitivity: import path mismatches';
|
|
7
|
+
|
|
8
|
+
// File extensions to scan for import statements
|
|
9
|
+
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.mts']);
|
|
10
|
+
|
|
11
|
+
// Directories to skip
|
|
12
|
+
const SKIP_DIRS = new Set([
|
|
13
|
+
'node_modules', '.git', '.next', 'dist', 'build', '.vercel',
|
|
14
|
+
'.output', 'coverage', '__pycache__', '.cache',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Recursively collect all scannable files.
|
|
19
|
+
*/
|
|
20
|
+
function collectFiles(dir, files = []) {
|
|
21
|
+
let entries;
|
|
22
|
+
try {
|
|
23
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
24
|
+
} catch {
|
|
25
|
+
return files;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const entry of entries) {
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
31
|
+
collectFiles(path.join(dir, entry.name), files);
|
|
32
|
+
}
|
|
33
|
+
} else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
|
34
|
+
files.push(path.join(dir, entry.name));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract relative import paths from a file's content.
|
|
43
|
+
* Returns array of { line, importPath } objects.
|
|
44
|
+
*/
|
|
45
|
+
function extractImports(content) {
|
|
46
|
+
const imports = [];
|
|
47
|
+
const lines = content.split('\n');
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < lines.length; i++) {
|
|
50
|
+
const line = lines[i];
|
|
51
|
+
|
|
52
|
+
// Match: import ... from './path' or import ... from "../path"
|
|
53
|
+
// Match: require('./path') or require("../path")
|
|
54
|
+
// Match: import('./path') dynamic imports
|
|
55
|
+
const patterns = [
|
|
56
|
+
/(?:from|require|import)\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
|
57
|
+
/from\s+['"](\.[^'"]+)['"]/g,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
for (const pattern of patterns) {
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
63
|
+
imports.push({
|
|
64
|
+
line: i + 1,
|
|
65
|
+
importPath: match[1],
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return imports;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build a map of directory → Set of actual filenames (preserving case).
|
|
76
|
+
*/
|
|
77
|
+
function buildFilenameCache(projectRoot) {
|
|
78
|
+
const cache = new Map();
|
|
79
|
+
|
|
80
|
+
function walk(dir) {
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const names = new Set();
|
|
89
|
+
for (const entry of entries) {
|
|
90
|
+
names.add(entry.name);
|
|
91
|
+
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
92
|
+
walk(path.join(dir, entry.name));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
cache.set(dir, names);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
walk(projectRoot);
|
|
99
|
+
return cache;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Resolve an import path to an actual file, trying common extensions.
|
|
104
|
+
* Returns { resolved, actual } where:
|
|
105
|
+
* - resolved is the expected filename from the import
|
|
106
|
+
* - actual is the real filename on disk (may differ in case)
|
|
107
|
+
* - null if the file doesn't exist at all
|
|
108
|
+
*/
|
|
109
|
+
function resolveImport(importPath, fromFile, filenameCache) {
|
|
110
|
+
const fromDir = path.dirname(fromFile);
|
|
111
|
+
const resolved = path.resolve(fromDir, importPath);
|
|
112
|
+
const dir = path.dirname(resolved);
|
|
113
|
+
const basename = path.basename(resolved);
|
|
114
|
+
|
|
115
|
+
const dirFiles = filenameCache.get(dir);
|
|
116
|
+
if (!dirFiles) return null;
|
|
117
|
+
|
|
118
|
+
// Extensions to try if the import has no extension
|
|
119
|
+
const ext = path.extname(basename);
|
|
120
|
+
const candidates = ext
|
|
121
|
+
? [basename]
|
|
122
|
+
: [
|
|
123
|
+
`${basename}.js`, `${basename}.ts`, `${basename}.jsx`, `${basename}.tsx`,
|
|
124
|
+
`${basename}.mjs`, `${basename}.mts`,
|
|
125
|
+
// index files
|
|
126
|
+
`${basename}/index.js`, `${basename}/index.ts`,
|
|
127
|
+
`${basename}/index.jsx`, `${basename}/index.tsx`,
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const candidate of candidates) {
|
|
131
|
+
// Handle directory/index patterns
|
|
132
|
+
if (candidate.includes('/')) {
|
|
133
|
+
const [subDir, indexFile] = candidate.split('/');
|
|
134
|
+
const subDirPath = path.join(dir, subDir);
|
|
135
|
+
const subFiles = filenameCache.get(subDirPath);
|
|
136
|
+
|
|
137
|
+
if (subFiles) {
|
|
138
|
+
// Check if directory name itself has a case mismatch
|
|
139
|
+
if (dirFiles) {
|
|
140
|
+
for (const actual of dirFiles) {
|
|
141
|
+
if (actual.toLowerCase() === subDir.toLowerCase() && actual !== subDir) {
|
|
142
|
+
return { expected: subDir, actual, type: 'directory' };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Check index file inside the directory
|
|
147
|
+
for (const actual of subFiles) {
|
|
148
|
+
if (actual.toLowerCase() === indexFile.toLowerCase() && actual !== indexFile) {
|
|
149
|
+
return { expected: indexFile, actual, type: 'file' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Direct file match
|
|
157
|
+
for (const actual of dirFiles) {
|
|
158
|
+
if (actual.toLowerCase() === candidate.toLowerCase()) {
|
|
159
|
+
if (actual !== candidate) {
|
|
160
|
+
return { expected: candidate, actual, type: 'file' };
|
|
161
|
+
}
|
|
162
|
+
// Exact match — no case issue
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null; // File not found at all — not a case issue, just a missing file
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function run(projectRoot) {
|
|
172
|
+
const files = collectFiles(projectRoot);
|
|
173
|
+
|
|
174
|
+
if (files.length === 0) {
|
|
175
|
+
return {
|
|
176
|
+
status: 'skip',
|
|
177
|
+
message: `${name} — no JS/TS files found to scan`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const filenameCache = buildFilenameCache(projectRoot);
|
|
182
|
+
const mismatches = [];
|
|
183
|
+
|
|
184
|
+
for (const file of files) {
|
|
185
|
+
let content;
|
|
186
|
+
try {
|
|
187
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
188
|
+
} catch {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const imports = extractImports(content);
|
|
193
|
+
|
|
194
|
+
for (const imp of imports) {
|
|
195
|
+
const result = resolveImport(imp.importPath, file, filenameCache);
|
|
196
|
+
if (result) {
|
|
197
|
+
const relFile = path.relative(projectRoot, file);
|
|
198
|
+
mismatches.push({
|
|
199
|
+
file: relFile,
|
|
200
|
+
line: imp.line,
|
|
201
|
+
importPath: imp.importPath,
|
|
202
|
+
expected: result.expected,
|
|
203
|
+
actual: result.actual,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (mismatches.length === 0) {
|
|
210
|
+
return {
|
|
211
|
+
status: 'pass',
|
|
212
|
+
message: `${name} — all ${files.length} files checked, no case mismatches found`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
status: 'warn',
|
|
218
|
+
message: `${name} — ${mismatches.length} case mismatch(es) found (will fail on Vercel's Linux filesystem)`,
|
|
219
|
+
fix: 'Rename the import paths to exactly match the filename casing on disk',
|
|
220
|
+
details: mismatches.map((m) => ({
|
|
221
|
+
file: m.file,
|
|
222
|
+
line: m.line,
|
|
223
|
+
message: `Import "${m.importPath}" references "${m.expected}" but file on disk is "${m.actual}"`,
|
|
224
|
+
})),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = { name, run };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'Missing Engines Field: Node.js version';
|
|
7
|
+
|
|
8
|
+
async function run(projectRoot) {
|
|
9
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
10
|
+
|
|
11
|
+
if (!fs.existsSync(pkgPath)) {
|
|
12
|
+
return {
|
|
13
|
+
status: 'skip',
|
|
14
|
+
message: `${name} — no package.json found`,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let pkg;
|
|
19
|
+
try {
|
|
20
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return {
|
|
23
|
+
status: 'fail',
|
|
24
|
+
message: `${name} — could not parse package.json`,
|
|
25
|
+
fix: 'Fix JSON syntax errors in package.json',
|
|
26
|
+
details: [{ file: 'package.json' }],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!pkg.engines) {
|
|
31
|
+
return {
|
|
32
|
+
status: 'warn',
|
|
33
|
+
message: `${name} — no "engines" field in package.json`,
|
|
34
|
+
fix: 'Add "engines": { "node": ">=18.0.0" } to package.json to lock the Node.js version on your platform',
|
|
35
|
+
details: [{ file: 'package.json', message: 'Missing "engines" field — platform may default to an unexpected Node.js version' }],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!pkg.engines.node) {
|
|
40
|
+
return {
|
|
41
|
+
status: 'warn',
|
|
42
|
+
message: `${name} — "engines" exists but no "node" version specified`,
|
|
43
|
+
fix: 'Add "node": ">=18.0.0" inside the "engines" field in package.json',
|
|
44
|
+
details: [{ file: 'package.json', message: 'engines field exists but does not specify a node version' }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
status: 'pass',
|
|
50
|
+
message: `${name} — Node.js engine specified: ${pkg.engines.node}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { name, run };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'Env Var Check: undeclared environment variables';
|
|
7
|
+
|
|
8
|
+
// File extensions to scan
|
|
9
|
+
const SCAN_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx', '.mjs', '.mts']);
|
|
10
|
+
|
|
11
|
+
// Directories to skip
|
|
12
|
+
const SKIP_DIRS = new Set([
|
|
13
|
+
'node_modules', '.git', '.next', 'dist', 'build', '.vercel',
|
|
14
|
+
'.output', 'coverage', '__pycache__', '.cache',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
// Common built-in env vars to ignore
|
|
18
|
+
const BUILTIN_VARS = new Set([
|
|
19
|
+
'NODE_ENV', 'PORT', 'HOME', 'PATH', 'PWD', 'USER', 'HOSTNAME',
|
|
20
|
+
'CI', 'VERCEL', 'VERCEL_ENV', 'VERCEL_URL', 'VERCEL_REGION',
|
|
21
|
+
'RENDER', 'RENDER_EXTERNAL_URL', 'RENDER_SERVICE_NAME',
|
|
22
|
+
'IS_PULL_REQUEST', 'RENDER_GIT_COMMIT', 'RENDER_GIT_BRANCH',
|
|
23
|
+
'NEXT_RUNTIME', 'npm_lifecycle_event', 'npm_package_name',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Recursively collect all scannable files.
|
|
28
|
+
*/
|
|
29
|
+
function collectFiles(dir, files = []) {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
40
|
+
collectFiles(path.join(dir, entry.name), files);
|
|
41
|
+
}
|
|
42
|
+
} else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) {
|
|
43
|
+
files.push(path.join(dir, entry.name));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a .env file and return a Set of variable names.
|
|
52
|
+
*/
|
|
53
|
+
function parseEnvFile(filePath) {
|
|
54
|
+
const vars = new Set();
|
|
55
|
+
if (!fs.existsSync(filePath)) return vars;
|
|
56
|
+
|
|
57
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
58
|
+
const lines = content.split('\n');
|
|
59
|
+
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const trimmed = line.trim();
|
|
62
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
63
|
+
|
|
64
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
|
65
|
+
if (match) {
|
|
66
|
+
vars.add(match[1]);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return vars;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scan a file for process.env.VAR_NAME references.
|
|
75
|
+
* Returns array of { line, varName }.
|
|
76
|
+
*/
|
|
77
|
+
function extractEnvReferences(content) {
|
|
78
|
+
const refs = [];
|
|
79
|
+
const lines = content.split('\n');
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < lines.length; i++) {
|
|
82
|
+
const line = lines[i];
|
|
83
|
+
|
|
84
|
+
// Skip comment-only lines
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Match process.env.VARIABLE_NAME
|
|
91
|
+
const pattern = /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
92
|
+
let match;
|
|
93
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
94
|
+
refs.push({
|
|
95
|
+
line: i + 1,
|
|
96
|
+
varName: match[1],
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Match process.env['VAR_NAME'] or process.env["VAR_NAME"]
|
|
101
|
+
const bracketPattern = /process\.env\[['"]([A-Za-z_][A-Za-z0-9_]*)['"]\]/g;
|
|
102
|
+
while ((match = bracketPattern.exec(line)) !== null) {
|
|
103
|
+
refs.push({
|
|
104
|
+
line: i + 1,
|
|
105
|
+
varName: match[1],
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return refs;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function run(projectRoot) {
|
|
114
|
+
// Gather declared env vars from .env files
|
|
115
|
+
const envFiles = ['.env.example', '.env.local', '.env'];
|
|
116
|
+
const declaredVars = new Set();
|
|
117
|
+
let foundEnvFile = false;
|
|
118
|
+
|
|
119
|
+
for (const envFile of envFiles) {
|
|
120
|
+
const envPath = path.join(projectRoot, envFile);
|
|
121
|
+
if (fs.existsSync(envPath)) {
|
|
122
|
+
foundEnvFile = true;
|
|
123
|
+
const vars = parseEnvFile(envPath);
|
|
124
|
+
for (const v of vars) declaredVars.add(v);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Scan source files
|
|
129
|
+
const files = collectFiles(projectRoot);
|
|
130
|
+
|
|
131
|
+
if (files.length === 0) {
|
|
132
|
+
return {
|
|
133
|
+
status: 'skip',
|
|
134
|
+
message: `${name} — no JS/TS files found to scan`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Collect all env var references
|
|
139
|
+
const allRefs = new Map(); // varName → [{ file, line }]
|
|
140
|
+
|
|
141
|
+
for (const file of files) {
|
|
142
|
+
let content;
|
|
143
|
+
try {
|
|
144
|
+
content = fs.readFileSync(file, 'utf-8');
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const refs = extractEnvReferences(content);
|
|
150
|
+
for (const ref of refs) {
|
|
151
|
+
if (BUILTIN_VARS.has(ref.varName)) continue;
|
|
152
|
+
|
|
153
|
+
if (!allRefs.has(ref.varName)) {
|
|
154
|
+
allRefs.set(ref.varName, []);
|
|
155
|
+
}
|
|
156
|
+
allRefs.get(ref.varName).push({
|
|
157
|
+
file: path.relative(projectRoot, file),
|
|
158
|
+
line: ref.line,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (allRefs.size === 0) {
|
|
164
|
+
return {
|
|
165
|
+
status: 'pass',
|
|
166
|
+
message: `${name} — no custom environment variables referenced in code`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!foundEnvFile) {
|
|
171
|
+
// All referenced vars are undeclared since there's no env file
|
|
172
|
+
const varNames = [...allRefs.keys()];
|
|
173
|
+
return {
|
|
174
|
+
status: 'warn',
|
|
175
|
+
message: `${name} — ${varNames.length} env var(s) referenced but no .env / .env.example file found`,
|
|
176
|
+
fix: 'Create a .env.example file listing all required environment variables',
|
|
177
|
+
details: varNames.slice(0, 15).map((v) => {
|
|
178
|
+
const locs = allRefs.get(v);
|
|
179
|
+
return {
|
|
180
|
+
file: locs[0].file,
|
|
181
|
+
line: locs[0].line,
|
|
182
|
+
message: `process.env.${v} — not declared in any .env file`,
|
|
183
|
+
};
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Find undeclared vars
|
|
189
|
+
const undeclared = [];
|
|
190
|
+
for (const [varName, locations] of allRefs) {
|
|
191
|
+
if (!declaredVars.has(varName)) {
|
|
192
|
+
undeclared.push({ varName, locations });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (undeclared.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
status: 'pass',
|
|
199
|
+
message: `${name} — all ${allRefs.size} referenced env vars are declared`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
status: 'warn',
|
|
205
|
+
message: `${name} — ${undeclared.length} env var(s) referenced in code but not declared in .env files`,
|
|
206
|
+
fix: 'Add the missing variables to .env.example and configure them in your deployment platform\'s env settings',
|
|
207
|
+
details: undeclared.slice(0, 15).map((u) => ({
|
|
208
|
+
file: u.locations[0].file,
|
|
209
|
+
line: u.locations[0].line,
|
|
210
|
+
message: `process.env.${u.varName} — used in ${u.locations.length} file(s) but not declared`,
|
|
211
|
+
})),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
module.exports = { name, run };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const name = 'Render Start Command: missing start configuration';
|
|
7
|
+
|
|
8
|
+
async function run(projectRoot) {
|
|
9
|
+
const pkgPath = path.join(projectRoot, 'package.json');
|
|
10
|
+
const procfilePath = path.join(projectRoot, 'Procfile');
|
|
11
|
+
const renderYamlPath = path.join(projectRoot, 'render.yaml');
|
|
12
|
+
|
|
13
|
+
const hasPkg = fs.existsSync(pkgPath);
|
|
14
|
+
const hasProcfile = fs.existsSync(procfilePath);
|
|
15
|
+
const hasRenderYaml = fs.existsSync(renderYamlPath);
|
|
16
|
+
|
|
17
|
+
// Check for Python project (has requirements.txt but no package.json)
|
|
18
|
+
const hasRequirements = fs.existsSync(path.join(projectRoot, 'requirements.txt'));
|
|
19
|
+
const isPython = hasRequirements && !hasPkg;
|
|
20
|
+
|
|
21
|
+
// ── Python project checks ──
|
|
22
|
+
if (isPython) {
|
|
23
|
+
if (hasProcfile) {
|
|
24
|
+
const procContent = fs.readFileSync(procfilePath, 'utf-8').trim();
|
|
25
|
+
if (procContent.match(/^web:/m)) {
|
|
26
|
+
return {
|
|
27
|
+
status: 'pass',
|
|
28
|
+
message: `${name} — Procfile found with web process`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for common Python entry points
|
|
34
|
+
const commonEntries = ['app.py', 'main.py', 'wsgi.py', 'asgi.py', 'manage.py'];
|
|
35
|
+
const foundEntries = commonEntries.filter((f) => fs.existsSync(path.join(projectRoot, f)));
|
|
36
|
+
|
|
37
|
+
if (hasProcfile) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'pass',
|
|
40
|
+
message: `${name} — Procfile found`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (hasRenderYaml) {
|
|
45
|
+
return {
|
|
46
|
+
status: 'pass',
|
|
47
|
+
message: `${name} — render.yaml found with start command`,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
status: 'fail',
|
|
53
|
+
message: `${name} — no Procfile or render.yaml for Python project`,
|
|
54
|
+
fix: 'Create a Procfile with: web: gunicorn app:app (or your appropriate start command)',
|
|
55
|
+
details: foundEntries.length > 0
|
|
56
|
+
? [{ file: foundEntries[0], message: `Found "${foundEntries[0]}" — add a Procfile to specify how to start it` }]
|
|
57
|
+
: [{ message: 'No common Python entry point found (app.py, main.py, etc.)' }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Node.js project checks ──
|
|
62
|
+
if (!hasPkg) {
|
|
63
|
+
return {
|
|
64
|
+
status: 'skip',
|
|
65
|
+
message: `${name} — no package.json found, not a Node.js project`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If there's a Procfile, that takes precedence
|
|
70
|
+
if (hasProcfile) {
|
|
71
|
+
return {
|
|
72
|
+
status: 'pass',
|
|
73
|
+
message: `${name} — Procfile found`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If there's a render.yaml, that takes precedence
|
|
78
|
+
if (hasRenderYaml) {
|
|
79
|
+
const content = fs.readFileSync(renderYamlPath, 'utf-8');
|
|
80
|
+
if (content.includes('startCommand')) {
|
|
81
|
+
return {
|
|
82
|
+
status: 'pass',
|
|
83
|
+
message: `${name} — render.yaml with startCommand found`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let pkg;
|
|
89
|
+
try {
|
|
90
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return {
|
|
93
|
+
status: 'fail',
|
|
94
|
+
message: `${name} — could not parse package.json`,
|
|
95
|
+
fix: 'Fix JSON syntax errors in package.json',
|
|
96
|
+
details: [{ file: 'package.json' }],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check for "start" script
|
|
101
|
+
if (pkg.scripts && pkg.scripts.start) {
|
|
102
|
+
return {
|
|
103
|
+
status: 'pass',
|
|
104
|
+
message: `${name} — "start" script found: "${pkg.scripts.start}"`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Check if "main" field points to a file
|
|
109
|
+
if (pkg.main && fs.existsSync(path.join(projectRoot, pkg.main))) {
|
|
110
|
+
return {
|
|
111
|
+
status: 'warn',
|
|
112
|
+
message: `${name} — no "start" script, but "main" field points to "${pkg.main}"`,
|
|
113
|
+
fix: 'Add a "start" script to package.json, e.g.: "start": "node ' + pkg.main + '"',
|
|
114
|
+
details: [{ file: 'package.json', message: 'Render needs an explicit start command; "main" alone may not be used' }],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
status: 'fail',
|
|
120
|
+
message: `${name} — no start command found`,
|
|
121
|
+
fix: 'Add a "start" script to package.json (e.g. "start": "node server.js") or create a Procfile',
|
|
122
|
+
details: [
|
|
123
|
+
{ file: 'package.json', message: 'No "start" script in "scripts"' },
|
|
124
|
+
{ message: 'No Procfile found' },
|
|
125
|
+
hasRenderYaml
|
|
126
|
+
? { file: 'render.yaml', message: 'render.yaml exists but has no "startCommand"' }
|
|
127
|
+
: { message: 'No render.yaml found' },
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { name, run };
|
package/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
|
|
7
|
+
// Dynamically load all check modules from /checks/
|
|
8
|
+
const checksDir = path.join(__dirname, 'checks');
|
|
9
|
+
const checkFiles = fs.readdirSync(checksDir)
|
|
10
|
+
.filter((f) => f.endsWith('.js'))
|
|
11
|
+
.sort();
|
|
12
|
+
|
|
13
|
+
const checks = checkFiles.map((f) => {
|
|
14
|
+
const mod = require(path.join(checksDir, f));
|
|
15
|
+
return {
|
|
16
|
+
name: mod.name || f.replace('.js', ''),
|
|
17
|
+
run: mod.run,
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const STATUS_ICONS = {
|
|
22
|
+
pass: chalk.green('✅'),
|
|
23
|
+
warn: chalk.yellow('⚠️ '),
|
|
24
|
+
fail: chalk.red('❌'),
|
|
25
|
+
skip: chalk.gray('⏭️ '),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATUS_COLORS = {
|
|
29
|
+
pass: chalk.green,
|
|
30
|
+
warn: chalk.yellow,
|
|
31
|
+
fail: chalk.red,
|
|
32
|
+
skip: chalk.gray,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a single result line.
|
|
37
|
+
*/
|
|
38
|
+
function formatResult(result) {
|
|
39
|
+
const icon = STATUS_ICONS[result.status] || '?';
|
|
40
|
+
const color = STATUS_COLORS[result.status] || chalk.white;
|
|
41
|
+
const lines = [];
|
|
42
|
+
|
|
43
|
+
lines.push(`${icon} ${color.bold(result.message)}`);
|
|
44
|
+
|
|
45
|
+
if (result.details && result.details.length > 0) {
|
|
46
|
+
for (const detail of result.details) {
|
|
47
|
+
const loc = detail.file
|
|
48
|
+
? chalk.dim(` ${detail.file}${detail.line ? `:${detail.line}` : ''}`)
|
|
49
|
+
: '';
|
|
50
|
+
if (loc) lines.push(loc);
|
|
51
|
+
if (detail.message) lines.push(chalk.dim(` → ${detail.message}`));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (result.fix && result.status !== 'pass') {
|
|
56
|
+
lines.push(chalk.cyan(` 💡 Fix: ${result.fix}`));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Run all checks against the given project root.
|
|
64
|
+
* Returns exit code: 1 if any ❌, 0 otherwise.
|
|
65
|
+
*/
|
|
66
|
+
async function runAllChecks(projectRoot) {
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(chalk.bold.underline(` predeploy-check`) + chalk.dim(` scanning ${projectRoot}`));
|
|
69
|
+
console.log(chalk.dim(' ─'.repeat(30)));
|
|
70
|
+
console.log('');
|
|
71
|
+
|
|
72
|
+
let hasFail = false;
|
|
73
|
+
let passCount = 0;
|
|
74
|
+
let warnCount = 0;
|
|
75
|
+
let failCount = 0;
|
|
76
|
+
let skipCount = 0;
|
|
77
|
+
|
|
78
|
+
for (const check of checks) {
|
|
79
|
+
try {
|
|
80
|
+
const results = await check.run(projectRoot);
|
|
81
|
+
|
|
82
|
+
// A check can return a single result or an array of results
|
|
83
|
+
const resultArray = Array.isArray(results) ? results : [results];
|
|
84
|
+
|
|
85
|
+
for (const result of resultArray) {
|
|
86
|
+
console.log(formatResult(result));
|
|
87
|
+
console.log('');
|
|
88
|
+
|
|
89
|
+
if (result.status === 'fail') {
|
|
90
|
+
hasFail = true;
|
|
91
|
+
failCount++;
|
|
92
|
+
} else if (result.status === 'warn') {
|
|
93
|
+
warnCount++;
|
|
94
|
+
} else if (result.status === 'skip') {
|
|
95
|
+
skipCount++;
|
|
96
|
+
} else {
|
|
97
|
+
passCount++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.log(chalk.red(`❌ ${check.name}: internal error — ${err.message}`));
|
|
102
|
+
console.log('');
|
|
103
|
+
hasFail = true;
|
|
104
|
+
failCount++;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Summary bar
|
|
109
|
+
console.log(chalk.dim(' ─'.repeat(30)));
|
|
110
|
+
|
|
111
|
+
const parts = [];
|
|
112
|
+
if (passCount > 0) parts.push(chalk.green(`${passCount} passed`));
|
|
113
|
+
if (warnCount > 0) parts.push(chalk.yellow(`${warnCount} warnings`));
|
|
114
|
+
if (failCount > 0) parts.push(chalk.red(`${failCount} failed`));
|
|
115
|
+
if (skipCount > 0) parts.push(chalk.gray(`${skipCount} skipped`));
|
|
116
|
+
|
|
117
|
+
console.log(`\n ${chalk.bold('Summary:')} ${parts.join(chalk.dim(' · '))}`);
|
|
118
|
+
|
|
119
|
+
if (hasFail) {
|
|
120
|
+
console.log(chalk.red.bold('\n Deploy will likely fail. Fix ❌ issues above.\n'));
|
|
121
|
+
} else if (warnCount > 0) {
|
|
122
|
+
console.log(chalk.yellow('\n Some warnings detected. Review ⚠️ items above.\n'));
|
|
123
|
+
} else {
|
|
124
|
+
console.log(chalk.green.bold('\n All checks passed! You\'re good to deploy. 🚀\n'));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return hasFail ? 1 : 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { runAllChecks };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "predeploy-check",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Scan a project folder and flag known deployment-failure patterns for Render and Vercel before you push.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"predeploy-check": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/cli.js",
|
|
11
|
+
"test": "node bin/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"deploy",
|
|
15
|
+
"vercel",
|
|
16
|
+
"render",
|
|
17
|
+
"lint",
|
|
18
|
+
"pre-deploy",
|
|
19
|
+
"ci",
|
|
20
|
+
"deployment",
|
|
21
|
+
"check"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"chalk": "^4.1.2"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=14.0.0"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"checks/",
|
|
34
|
+
"index.js",
|
|
35
|
+
"README.md"
|
|
36
|
+
]
|
|
37
|
+
}
|