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 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
+ }