safelaunch 1.0.21 → 1.0.23
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/package.json +1 -1
- package/safelaunch/README.md +38 -33
- package/safelaunch/bin/safelaunch.js +7 -4
- package/safelaunch/src/scan.js +7 -5
- package/safelaunch/src/setup.js +488 -0
package/package.json
CHANGED
package/safelaunch/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/safelaunch)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
One command scans your project, locks your environment, and blocks bad deploys forever.
|
|
8
8
|
|
|
9
9
|
Works with **Node.js, Next.js, Vite, and Create React App**.
|
|
10
10
|
|
|
@@ -12,21 +12,21 @@ Works with **Node.js, Next.js, Vite, and Create React App**.
|
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Get protected in one command
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
npx safelaunch
|
|
18
|
+
npx safelaunch setup
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
No installation. No config.
|
|
21
|
+
No installation. No config. Run it in any JavaScript project.
|
|
22
|
+
|
|
23
|
+
safelaunch scans your entire project, shows you exactly what would break your next deploy, and once everything is clean — generates your environment manifest and installs a git hook that blocks bad pushes automatically.
|
|
22
24
|
|
|
23
25
|
```
|
|
24
|
-
|
|
26
|
+
Running safelaunch setup...
|
|
25
27
|
Detected: Next.js
|
|
26
28
|
|
|
27
|
-
🚨
|
|
28
|
-
|
|
29
|
-
This project is NOT safe to deploy
|
|
29
|
+
🚨 Issues found — fix these first
|
|
30
30
|
|
|
31
31
|
3 issues found
|
|
32
32
|
2 critical · 1 warning
|
|
@@ -61,22 +61,33 @@ This project is NOT safe to deploy
|
|
|
61
61
|
|
|
62
62
|
────────────────────────────
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
💡 Fix the issues above, then run:
|
|
65
|
+
|
|
66
|
+
npx safelaunch setup
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Fix the issues and run it again — safelaunch will complete the setup automatically:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Running safelaunch setup...
|
|
73
|
+
Detected: Next.js
|
|
74
|
+
|
|
75
|
+
✅ All checks passed — setting up safelaunch...
|
|
76
|
+
|
|
77
|
+
────────────────────────────
|
|
65
78
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- Dependencies installed
|
|
69
|
-
- No duplicate variables
|
|
70
|
-
- NODE_ENV configured
|
|
79
|
+
env.manifest.json ✅ generated
|
|
80
|
+
git hook ✅ installed
|
|
71
81
|
|
|
72
82
|
────────────────────────────
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
🛡️ You're protected
|
|
75
85
|
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
safelaunch will validate your environment
|
|
87
|
+
automatically before every git push.
|
|
78
88
|
|
|
79
|
-
|
|
89
|
+
To validate manually at any time:
|
|
90
|
+
safelaunch validate
|
|
80
91
|
```
|
|
81
92
|
|
|
82
93
|
---
|
|
@@ -120,36 +131,30 @@ npm install -g safelaunch
|
|
|
120
131
|
|
|
121
132
|
---
|
|
122
133
|
|
|
123
|
-
##
|
|
134
|
+
## Individual commands
|
|
124
135
|
|
|
125
|
-
|
|
136
|
+
If you want to run steps individually:
|
|
126
137
|
|
|
127
|
-
**
|
|
138
|
+
**Quick scan — no setup needed**
|
|
139
|
+
```bash
|
|
140
|
+
safelaunch scan
|
|
141
|
+
```
|
|
128
142
|
|
|
143
|
+
**Generate your environment manifest**
|
|
129
144
|
```bash
|
|
130
145
|
safelaunch init
|
|
131
146
|
```
|
|
132
147
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
**Step 2 — Validate before every deploy**
|
|
136
|
-
|
|
148
|
+
**Validate against your manifest**
|
|
137
149
|
```bash
|
|
138
150
|
safelaunch validate
|
|
139
151
|
```
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
## Never think about it again
|
|
146
|
-
|
|
153
|
+
**Install the git hook manually**
|
|
147
154
|
```bash
|
|
148
155
|
safelaunch hook install
|
|
149
156
|
```
|
|
150
157
|
|
|
151
|
-
Installs a git hook that blocks `git push` automatically if validation fails. Set it once, forget about it.
|
|
152
|
-
|
|
153
158
|
---
|
|
154
159
|
|
|
155
160
|
## CI Integration
|
|
@@ -9,6 +9,8 @@ if (command === 'validate') {
|
|
|
9
9
|
require(path.join(__dirname, '../src/init.js'));
|
|
10
10
|
} else if (command === 'scan') {
|
|
11
11
|
require(path.join(__dirname, '../src/scan.js'));
|
|
12
|
+
} else if (command === 'setup') {
|
|
13
|
+
require(path.join(__dirname, '../src/setup.js'));
|
|
12
14
|
} else if (command === 'hook') {
|
|
13
15
|
const { installHook, uninstallHook } = require(path.join(__dirname, '../src/hook.js'));
|
|
14
16
|
if (subcommand === 'install') {
|
|
@@ -22,9 +24,10 @@ if (command === 'validate') {
|
|
|
22
24
|
}
|
|
23
25
|
} else {
|
|
24
26
|
console.log('\nUsage:');
|
|
25
|
-
console.log(' safelaunch
|
|
26
|
-
console.log(' safelaunch
|
|
27
|
-
console.log(' safelaunch
|
|
28
|
-
console.log(' safelaunch
|
|
27
|
+
console.log(' safelaunch setup (recommended — scans, locks, and protects in one step)');
|
|
28
|
+
console.log(' safelaunch scan (quick scan, no setup needed)');
|
|
29
|
+
console.log(' safelaunch init (generate env.manifest.json)');
|
|
30
|
+
console.log(' safelaunch validate (validate against manifest)');
|
|
31
|
+
console.log(' safelaunch hook install (install git hook)');
|
|
29
32
|
console.log(' safelaunch hook uninstall\n');
|
|
30
33
|
}
|
package/safelaunch/src/scan.js
CHANGED
|
@@ -99,11 +99,12 @@ function detectProjectType(cwd) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
function scanFiles(dir, extensions, pattern, found = new Set()) {
|
|
102
|
-
|
|
102
|
+
let items;
|
|
103
|
+
try { items = fs.readdirSync(dir); } catch (_) { return found; }
|
|
103
104
|
for (const item of items) {
|
|
104
105
|
if (item === 'node_modules' || item === '.git') continue;
|
|
105
106
|
const full = path.join(dir, item);
|
|
106
|
-
|
|
107
|
+
let stat; try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
107
108
|
if (stat.isDirectory()) {
|
|
108
109
|
scanFiles(full, extensions, pattern, found);
|
|
109
110
|
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
@@ -139,9 +140,9 @@ function checkDependencies(cwd) {
|
|
|
139
140
|
const packagePath = path.join(cwd, 'package.json');
|
|
140
141
|
const modulesPath = path.join(cwd, 'node_modules');
|
|
141
142
|
if (!fs.existsSync(packagePath)) return null;
|
|
142
|
-
if (!fs.existsSync(modulesPath)) return { notInstalled: true, missing: [] };
|
|
143
143
|
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
144
144
|
const deps = Object.keys(pkg.dependencies || {});
|
|
145
|
+
if (!fs.existsSync(modulesPath)) return deps.length > 0 ? { notInstalled: true, missing: [] } : null;
|
|
145
146
|
const missing = [];
|
|
146
147
|
for (const dep of deps) {
|
|
147
148
|
if (!fs.existsSync(path.join(modulesPath, dep))) missing.push(dep);
|
|
@@ -195,11 +196,12 @@ function checkHardcodedSecrets(cwd) {
|
|
|
195
196
|
const hits = [];
|
|
196
197
|
function walk(dir) {
|
|
197
198
|
try {
|
|
198
|
-
|
|
199
|
+
let items;
|
|
200
|
+
try { items = fs.readdirSync(dir); } catch (_) { return found; }
|
|
199
201
|
for (const item of items) {
|
|
200
202
|
if (item === 'node_modules' || item === '.git') continue;
|
|
201
203
|
const full = path.join(dir, item);
|
|
202
|
-
|
|
204
|
+
let stat; try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
203
205
|
if (stat.isDirectory()) {
|
|
204
206
|
walk(full);
|
|
205
207
|
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { track, shutdown } = require('./telemetry');
|
|
5
|
+
|
|
6
|
+
// ─── Shared utilities ─────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function impactBlock(lines) {
|
|
9
|
+
return ' Impact:\n' + lines.map(l => ' ' + l).join('\n');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const IMPACT = {
|
|
13
|
+
missing: (key) => impactBlock([
|
|
14
|
+
'Your app cannot use ' + key,
|
|
15
|
+
'\u2192 Will crash or behave incorrectly at runtime'
|
|
16
|
+
]),
|
|
17
|
+
empty: (key) => impactBlock([
|
|
18
|
+
key + ' is defined but has no value',
|
|
19
|
+
'\u2192 Your app will treat it as blank \u2014 this will break things'
|
|
20
|
+
]),
|
|
21
|
+
duplicate: (key) => impactBlock([
|
|
22
|
+
key + ' is defined more than once',
|
|
23
|
+
'\u2192 The last value silently wins \u2014 likely not what you want'
|
|
24
|
+
]),
|
|
25
|
+
depsNotInstalled: () => impactBlock([
|
|
26
|
+
'node_modules is missing entirely',
|
|
27
|
+
'\u2192 Your app will not start'
|
|
28
|
+
]),
|
|
29
|
+
depsDrift: (dep) => impactBlock([
|
|
30
|
+
dep + ' is in package.json but not installed',
|
|
31
|
+
'\u2192 Any code that imports it will fail'
|
|
32
|
+
]),
|
|
33
|
+
lockfileMissing: () => impactBlock([
|
|
34
|
+
'package-lock.json is missing',
|
|
35
|
+
'\u2192 npm install will resolve different versions each time \u2014 inconsistent deploys'
|
|
36
|
+
]),
|
|
37
|
+
prefixVite: (key) => impactBlock([
|
|
38
|
+
key + ' is missing the VITE_ prefix',
|
|
39
|
+
'\u2192 It will not be exposed to the browser \u2014 your frontend will not see it'
|
|
40
|
+
]),
|
|
41
|
+
prefixCra: (key) => impactBlock([
|
|
42
|
+
key + ' is missing the REACT_APP_ prefix',
|
|
43
|
+
'\u2192 It will not be exposed to the browser \u2014 your frontend will not see it'
|
|
44
|
+
]),
|
|
45
|
+
noEnvFile: () => impactBlock([
|
|
46
|
+
'No .env file found',
|
|
47
|
+
'\u2192 Every environment variable your app needs will be missing'
|
|
48
|
+
]),
|
|
49
|
+
envInGit: (file) => impactBlock([
|
|
50
|
+
file + ' is committed to your git history',
|
|
51
|
+
'\u2192 Your secrets are exposed to anyone with repo access'
|
|
52
|
+
]),
|
|
53
|
+
envNotIgnored: () => impactBlock([
|
|
54
|
+
'.env is not in your .gitignore',
|
|
55
|
+
'\u2192 You are one git add away from leaking your secrets'
|
|
56
|
+
]),
|
|
57
|
+
envExampleOutOfSync: (keys) => impactBlock([
|
|
58
|
+
keys.join(', ') + ' are in .env but missing from .env.example',
|
|
59
|
+
'\u2192 Teammates cloning this repo will not know these vars exist'
|
|
60
|
+
]),
|
|
61
|
+
hardcodedSecret: (file) => impactBlock([
|
|
62
|
+
'Possible secret found hardcoded in ' + file,
|
|
63
|
+
'\u2192 Secrets in source code get committed and exposed in version history'
|
|
64
|
+
]),
|
|
65
|
+
buildScriptMissing: () => impactBlock([
|
|
66
|
+
'No build script found in package.json',
|
|
67
|
+
'\u2192 Your deploy pipeline will not know how to build this project'
|
|
68
|
+
]),
|
|
69
|
+
uncommittedChanges: () => impactBlock([
|
|
70
|
+
'You have uncommitted changes',
|
|
71
|
+
'\u2192 These changes will not be included in your deploy'
|
|
72
|
+
]),
|
|
73
|
+
unpushedCommits: () => impactBlock([
|
|
74
|
+
'You have commits that have not been pushed',
|
|
75
|
+
'\u2192 Your deploy may not include your latest changes'
|
|
76
|
+
]),
|
|
77
|
+
typescriptErrors: () => impactBlock([
|
|
78
|
+
'Your project has TypeScript type errors',
|
|
79
|
+
'\u2192 These may cause your build to fail in CI or production'
|
|
80
|
+
]),
|
|
81
|
+
configFileMissing: (file) => impactBlock([
|
|
82
|
+
file + ' is missing',
|
|
83
|
+
'\u2192 Your framework will not know how to build or run this project'
|
|
84
|
+
]),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const DIVIDER = '\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500';
|
|
88
|
+
|
|
89
|
+
function detectProjectType(cwd) {
|
|
90
|
+
if (fs.existsSync(path.join(cwd, 'vite.config.js')) ||
|
|
91
|
+
fs.existsSync(path.join(cwd, 'vite.config.ts'))) return 'vite';
|
|
92
|
+
if (fs.existsSync(path.join(cwd, 'next.config.js')) ||
|
|
93
|
+
fs.existsSync(path.join(cwd, 'next.config.ts'))) return 'next';
|
|
94
|
+
const packagePath = path.join(cwd, 'package.json');
|
|
95
|
+
if (fs.existsSync(packagePath)) {
|
|
96
|
+
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
97
|
+
const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
|
|
98
|
+
if (deps['react-scripts']) return 'cra';
|
|
99
|
+
}
|
|
100
|
+
return 'node';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function scanFiles(dir, extensions, pattern, found = new Set()) {
|
|
104
|
+
let items;
|
|
105
|
+
try { items = fs.readdirSync(dir); } catch (_) { return found; }
|
|
106
|
+
for (const item of items) {
|
|
107
|
+
if (item === 'node_modules' || item === '.git') continue;
|
|
108
|
+
const full = path.join(dir, item);
|
|
109
|
+
let stat;
|
|
110
|
+
try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
111
|
+
if (stat.isDirectory()) {
|
|
112
|
+
scanFiles(full, extensions, pattern, found);
|
|
113
|
+
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
114
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
115
|
+
const matches = content.matchAll(pattern);
|
|
116
|
+
for (const match of matches) found.add(match[1]);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return found;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readEnvDetailed(cwd) {
|
|
123
|
+
const envPath = path.join(cwd, '.env');
|
|
124
|
+
if (!fs.existsSync(envPath)) return null;
|
|
125
|
+
const envVars = {};
|
|
126
|
+
const duplicates = new Set();
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
|
|
129
|
+
for (const line of lines) {
|
|
130
|
+
const match = line.match(/^([^=]+)=(.*)$/);
|
|
131
|
+
if (match) {
|
|
132
|
+
const key = match[1].trim();
|
|
133
|
+
const val = match[2].trim();
|
|
134
|
+
if (seen.has(key)) duplicates.add(key);
|
|
135
|
+
seen.add(key);
|
|
136
|
+
envVars[key] = val;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { envVars, duplicates: [...duplicates] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function checkDependencies(cwd) {
|
|
143
|
+
const packagePath = path.join(cwd, 'package.json');
|
|
144
|
+
const modulesPath = path.join(cwd, 'node_modules');
|
|
145
|
+
if (!fs.existsSync(packagePath)) return null;
|
|
146
|
+
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
147
|
+
const deps = Object.keys(pkg.dependencies || {});
|
|
148
|
+
if (!fs.existsSync(modulesPath)) return deps.length > 0 ? { notInstalled: true, missing: [] } : null;
|
|
149
|
+
const missing = [];
|
|
150
|
+
for (const dep of deps) {
|
|
151
|
+
if (!fs.existsSync(path.join(modulesPath, dep))) missing.push(dep);
|
|
152
|
+
}
|
|
153
|
+
return { notInstalled: false, missing };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function checkLockfile(cwd) {
|
|
157
|
+
return !fs.existsSync(path.join(cwd, 'package-lock.json')) &&
|
|
158
|
+
!fs.existsSync(path.join(cwd, 'yarn.lock')) &&
|
|
159
|
+
!fs.existsSync(path.join(cwd, 'pnpm-lock.yaml'));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function checkEnvInGit(cwd) {
|
|
163
|
+
try {
|
|
164
|
+
const tracked = execSync('git ls-files', { cwd, stdio: ['pipe', 'pipe', 'ignore'] }).toString();
|
|
165
|
+
return tracked.split('\n').filter(f => f === '.env' || /^\.env\.(production|staging|live)$/.test(f));
|
|
166
|
+
} catch (_) { return []; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkEnvInGitignore(cwd) {
|
|
170
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
171
|
+
if (!fs.existsSync(gitignorePath)) return false;
|
|
172
|
+
return fs.readFileSync(gitignorePath, 'utf8').split('\n').some(line => line.trim() === '.env');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function checkEnvExampleSync(cwd, envVars) {
|
|
176
|
+
const examplePath = path.join(cwd, '.env.example');
|
|
177
|
+
if (!fs.existsSync(examplePath)) return [];
|
|
178
|
+
const exampleKeys = new Set();
|
|
179
|
+
for (const line of fs.readFileSync(examplePath, 'utf8').split('\n')) {
|
|
180
|
+
const match = line.match(/^([^=]+)=/);
|
|
181
|
+
if (match) exampleKeys.add(match[1].trim());
|
|
182
|
+
}
|
|
183
|
+
return Object.keys(envVars).filter(k => !exampleKeys.has(k));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function checkHardcodedSecrets(cwd) {
|
|
187
|
+
const extensions = ['.js', '.ts', '.jsx', '.tsx'];
|
|
188
|
+
const secretPatterns = [
|
|
189
|
+
/sk_live_[a-zA-Z0-9]{20,}/,
|
|
190
|
+
/pk_live_[a-zA-Z0-9]{20,}/,
|
|
191
|
+
/AKIA[0-9A-Z]{16}/,
|
|
192
|
+
/-----BEGIN (RSA |EC )?PRIVATE KEY-----/,
|
|
193
|
+
/ghp_[a-zA-Z0-9]{36}/,
|
|
194
|
+
/xox[baprs]-[0-9a-zA-Z-]{10,}/,
|
|
195
|
+
];
|
|
196
|
+
const hits = [];
|
|
197
|
+
function walk(dir) {
|
|
198
|
+
let items;
|
|
199
|
+
try { items = fs.readdirSync(dir); } catch (_) { return; }
|
|
200
|
+
for (const item of items) {
|
|
201
|
+
if (item === 'node_modules' || item === '.git') continue;
|
|
202
|
+
const full = path.join(dir, item);
|
|
203
|
+
let stat;
|
|
204
|
+
try { stat = fs.statSync(full); } catch (_) { continue; }
|
|
205
|
+
if (stat.isDirectory()) {
|
|
206
|
+
walk(full);
|
|
207
|
+
} else if (extensions.some(ext => item.endsWith(ext))) {
|
|
208
|
+
const content = fs.readFileSync(full, 'utf8');
|
|
209
|
+
for (const pattern of secretPatterns) {
|
|
210
|
+
if (pattern.test(content)) { hits.push(path.relative(cwd, full)); break; }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
walk(cwd);
|
|
216
|
+
return hits;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function checkBuildScript(cwd) {
|
|
220
|
+
const packagePath = path.join(cwd, 'package.json');
|
|
221
|
+
if (!fs.existsSync(packagePath)) return false;
|
|
222
|
+
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
223
|
+
return !!(pkg.scripts && pkg.scripts.build);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function checkGitStatus(cwd) {
|
|
227
|
+
try {
|
|
228
|
+
const status = execSync('git status --porcelain', { cwd, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
|
|
229
|
+
const uncommitted = status.length > 0;
|
|
230
|
+
let unpushed = false;
|
|
231
|
+
try {
|
|
232
|
+
const ahead = execSync('git log @{u}.. --oneline', { cwd, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim();
|
|
233
|
+
unpushed = ahead.length > 0;
|
|
234
|
+
} catch (_) {}
|
|
235
|
+
return { uncommitted, unpushed };
|
|
236
|
+
} catch (_) { return { uncommitted: false, unpushed: false }; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function checkTypeScript(cwd) {
|
|
240
|
+
if (!fs.existsSync(path.join(cwd, 'tsconfig.json'))) return false;
|
|
241
|
+
try {
|
|
242
|
+
execSync('npx tsc --noEmit', { cwd, stdio: ['pipe', 'pipe', 'ignore'] });
|
|
243
|
+
return false;
|
|
244
|
+
} catch (_) { return true; }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function checkRequiredConfigFiles(cwd, projectType) {
|
|
248
|
+
const missing = [];
|
|
249
|
+
if (projectType === 'next') {
|
|
250
|
+
if (!fs.existsSync(path.join(cwd, 'next.config.js')) && !fs.existsSync(path.join(cwd, 'next.config.ts'))) missing.push('next.config.js');
|
|
251
|
+
}
|
|
252
|
+
if (projectType === 'vite') {
|
|
253
|
+
if (!fs.existsSync(path.join(cwd, 'vite.config.js')) && !fs.existsSync(path.join(cwd, 'vite.config.ts'))) missing.push('vite.config.js');
|
|
254
|
+
}
|
|
255
|
+
return missing;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderIssue(number, title, impact) {
|
|
259
|
+
return number + '. ' + title + '\n\n' + impact + '\n';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Generate manifest ────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
function generateManifest(cwd, projectType, found) {
|
|
265
|
+
const manifestPath = path.join(cwd, 'env.manifest.json');
|
|
266
|
+
const variables = {};
|
|
267
|
+
for (const key of [...found].sort()) {
|
|
268
|
+
variables[key] = { required: true, description: '' };
|
|
269
|
+
}
|
|
270
|
+
const manifest = {
|
|
271
|
+
version: '1',
|
|
272
|
+
projectType,
|
|
273
|
+
runtime: { node: process.version.replace('v', '').split('.')[0] },
|
|
274
|
+
variables
|
|
275
|
+
};
|
|
276
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Install hook ─────────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
function installHook(cwd) {
|
|
282
|
+
const gitDir = path.join(cwd, '.git');
|
|
283
|
+
if (!fs.existsSync(gitDir)) return false;
|
|
284
|
+
const hooksDir = path.join(gitDir, 'hooks');
|
|
285
|
+
const hookPath = path.join(hooksDir, 'pre-push');
|
|
286
|
+
if (!fs.existsSync(hooksDir)) fs.mkdirSync(hooksDir, { recursive: true });
|
|
287
|
+
const hookScript = `#!/bin/sh\n# safelaunch pre-push hook\necho ""\necho "Running safelaunch validate..."\necho ""\nsafelaunch validate\nif [ $? -ne 0 ]; then\n echo ""\n echo "Push blocked by safelaunch. Fix the issues above before pushing."\n echo ""\n exit 1\nfi\n`;
|
|
288
|
+
if (fs.existsSync(hookPath)) {
|
|
289
|
+
const existing = fs.readFileSync(hookPath, 'utf8');
|
|
290
|
+
if (existing.includes('safelaunch validate')) return 'already';
|
|
291
|
+
fs.appendFileSync(hookPath, '\n' + hookScript);
|
|
292
|
+
} else {
|
|
293
|
+
fs.writeFileSync(hookPath, hookScript);
|
|
294
|
+
fs.chmodSync(hookPath, '755');
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
async function setup() {
|
|
302
|
+
const cwd = process.cwd();
|
|
303
|
+
const projectType = detectProjectType(cwd);
|
|
304
|
+
const typeLabels = { vite: 'Vite', next: 'Next.js', cra: 'Create React App', node: 'Node.js' };
|
|
305
|
+
|
|
306
|
+
console.log('');
|
|
307
|
+
console.log('Running safelaunch setup...');
|
|
308
|
+
console.log('Detected: ' + typeLabels[projectType]);
|
|
309
|
+
console.log('');
|
|
310
|
+
|
|
311
|
+
const extensions = ['.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte'];
|
|
312
|
+
let pattern;
|
|
313
|
+
if (projectType === 'vite') {
|
|
314
|
+
pattern = /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g;
|
|
315
|
+
} else if (projectType === 'cra') {
|
|
316
|
+
pattern = /process\.env\.(REACT_APP_[A-Z0-9_]*)/g;
|
|
317
|
+
} else {
|
|
318
|
+
pattern = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const found = scanFiles(cwd, extensions, pattern);
|
|
322
|
+
const result = readEnvDetailed(cwd);
|
|
323
|
+
const drift = checkDependencies(cwd);
|
|
324
|
+
const lockfileMissing = checkLockfile(cwd);
|
|
325
|
+
const envInGit = checkEnvInGit(cwd);
|
|
326
|
+
const envIgnored = checkEnvInGitignore(cwd);
|
|
327
|
+
const hardcodedSecrets = checkHardcodedSecrets(cwd);
|
|
328
|
+
const hasBuildScript = checkBuildScript(cwd);
|
|
329
|
+
const gitStatus = checkGitStatus(cwd);
|
|
330
|
+
const hasTypeScriptErrors = checkTypeScript(cwd);
|
|
331
|
+
const missingConfigFiles = checkRequiredConfigFiles(cwd, projectType);
|
|
332
|
+
|
|
333
|
+
let missing = [], empty = [], present = [], duplicates = [], envExampleOutOfSync = [];
|
|
334
|
+
|
|
335
|
+
if (result) {
|
|
336
|
+
const { envVars, duplicates: dups } = result;
|
|
337
|
+
duplicates = dups;
|
|
338
|
+
envExampleOutOfSync = checkEnvExampleSync(cwd, envVars);
|
|
339
|
+
for (const key of [...found].sort()) {
|
|
340
|
+
if (!(key in envVars)) missing.push(key);
|
|
341
|
+
else if (envVars[key] === '') empty.push(key);
|
|
342
|
+
else present.push(key);
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
missing = [...found].sort();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const prefixWarnings = [];
|
|
349
|
+
for (const key of found) {
|
|
350
|
+
if (key === 'NODE_ENV') continue;
|
|
351
|
+
if (projectType === 'vite' && !key.startsWith('VITE_')) prefixWarnings.push({ key, type: 'vite' });
|
|
352
|
+
if (projectType === 'cra' && !key.startsWith('REACT_APP_')) prefixWarnings.push({ key, type: 'cra' });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const criticalIssues = [];
|
|
356
|
+
const warningIssues = [];
|
|
357
|
+
|
|
358
|
+
if (!result) criticalIssues.push({ title: '.env file is missing', impact: IMPACT.noEnvFile() });
|
|
359
|
+
for (const key of missing) criticalIssues.push({ title: key + ' is missing', impact: IMPACT.missing(key) });
|
|
360
|
+
for (const key of empty) criticalIssues.push({ title: key + ' is empty', impact: IMPACT.empty(key) });
|
|
361
|
+
if (drift && drift.notInstalled) criticalIssues.push({ title: 'Dependencies are not installed', impact: IMPACT.depsNotInstalled() });
|
|
362
|
+
for (const file of envInGit) criticalIssues.push({ title: file + ' is tracked by git', impact: IMPACT.envInGit(file) });
|
|
363
|
+
for (const file of hardcodedSecrets) criticalIssues.push({ title: 'Hardcoded secret in ' + file, impact: IMPACT.hardcodedSecret(file) });
|
|
364
|
+
for (const file of missingConfigFiles) criticalIssues.push({ title: file + ' is missing', impact: IMPACT.configFileMissing(file) });
|
|
365
|
+
if (hasTypeScriptErrors) criticalIssues.push({ title: 'TypeScript errors found', impact: IMPACT.typescriptErrors() });
|
|
366
|
+
|
|
367
|
+
if (drift && !drift.notInstalled && drift.missing.length > 0) {
|
|
368
|
+
for (const dep of drift.missing) warningIssues.push({ title: dep + ' is not installed', impact: IMPACT.depsDrift(dep) });
|
|
369
|
+
}
|
|
370
|
+
if (lockfileMissing) warningIssues.push({ title: 'No lockfile found', impact: IMPACT.lockfileMissing() });
|
|
371
|
+
for (const key of duplicates) warningIssues.push({ title: key + ' is defined more than once', impact: IMPACT.duplicate(key) });
|
|
372
|
+
for (const { key, type } of prefixWarnings) {
|
|
373
|
+
if (type === 'vite') warningIssues.push({ title: key + ' is missing VITE_ prefix', impact: IMPACT.prefixVite(key) });
|
|
374
|
+
else warningIssues.push({ title: key + ' is missing REACT_APP_ prefix', impact: IMPACT.prefixCra(key) });
|
|
375
|
+
}
|
|
376
|
+
if (!envIgnored && fs.existsSync(path.join(cwd, '.env'))) warningIssues.push({ title: '.env is not in .gitignore', impact: IMPACT.envNotIgnored() });
|
|
377
|
+
if (envExampleOutOfSync.length > 0) warningIssues.push({ title: '.env.example is out of sync', impact: IMPACT.envExampleOutOfSync(envExampleOutOfSync) });
|
|
378
|
+
if (!hasBuildScript && fs.existsSync(path.join(cwd, 'package.json'))) warningIssues.push({ title: 'No build script in package.json', impact: IMPACT.buildScriptMissing() });
|
|
379
|
+
if (gitStatus.uncommitted) warningIssues.push({ title: 'You have uncommitted changes', impact: IMPACT.uncommittedChanges() });
|
|
380
|
+
if (gitStatus.unpushed) warningIssues.push({ title: 'You have unpushed commits', impact: IMPACT.unpushedCommits() });
|
|
381
|
+
|
|
382
|
+
const totalIssues = criticalIssues.length + warningIssues.length;
|
|
383
|
+
|
|
384
|
+
// ── Issues found — stop and show them ──
|
|
385
|
+
|
|
386
|
+
if (totalIssues > 0) {
|
|
387
|
+
const parts = [];
|
|
388
|
+
if (criticalIssues.length > 0) parts.push(criticalIssues.length + ' critical');
|
|
389
|
+
if (warningIssues.length > 0) parts.push(warningIssues.length + ' warning' + (warningIssues.length !== 1 ? 's' : ''));
|
|
390
|
+
|
|
391
|
+
console.log('\uD83D\uDEA8 Issues found — fix these first');
|
|
392
|
+
console.log('');
|
|
393
|
+
console.log(totalIssues + ' issue' + (totalIssues !== 1 ? 's' : '') + ' found');
|
|
394
|
+
console.log(parts.join(' \u00B7 '));
|
|
395
|
+
console.log('');
|
|
396
|
+
console.log(DIVIDER);
|
|
397
|
+
console.log('');
|
|
398
|
+
|
|
399
|
+
if (criticalIssues.length > 0) {
|
|
400
|
+
console.log('\uD83D\uDEA8 CRITICAL (will break your app)');
|
|
401
|
+
console.log('');
|
|
402
|
+
criticalIssues.forEach((issue, i) => {
|
|
403
|
+
console.log(renderIssue(i + 1, issue.title, issue.impact));
|
|
404
|
+
if (i < criticalIssues.length - 1) { console.log('---'); console.log(''); }
|
|
405
|
+
});
|
|
406
|
+
console.log('');
|
|
407
|
+
console.log(DIVIDER);
|
|
408
|
+
console.log('');
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (warningIssues.length > 0) {
|
|
412
|
+
console.log('\u26A0\uFE0F WARNINGS (may cause issues)');
|
|
413
|
+
console.log('');
|
|
414
|
+
const offset = criticalIssues.length;
|
|
415
|
+
warningIssues.forEach((issue, i) => {
|
|
416
|
+
console.log(renderIssue(offset + i + 1, issue.title, issue.impact));
|
|
417
|
+
if (i < warningIssues.length - 1) { console.log('---'); console.log(''); }
|
|
418
|
+
});
|
|
419
|
+
console.log('');
|
|
420
|
+
console.log(DIVIDER);
|
|
421
|
+
console.log('');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log('\uD83D\uDCA1 Fix the issues above, then run:');
|
|
425
|
+
console.log('');
|
|
426
|
+
console.log(' npx safelaunch setup');
|
|
427
|
+
console.log('');
|
|
428
|
+
|
|
429
|
+
await track('safelaunch_setup_run', {
|
|
430
|
+
project_type: projectType,
|
|
431
|
+
passed: false,
|
|
432
|
+
issues: totalIssues,
|
|
433
|
+
critical: criticalIssues.length,
|
|
434
|
+
warnings: warningIssues.length
|
|
435
|
+
});
|
|
436
|
+
await shutdown();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── All clean — generate manifest and install hook ──
|
|
441
|
+
|
|
442
|
+
console.log('\u2705 All checks passed — setting up safelaunch...');
|
|
443
|
+
console.log('');
|
|
444
|
+
console.log(DIVIDER);
|
|
445
|
+
console.log('');
|
|
446
|
+
|
|
447
|
+
// Generate manifest
|
|
448
|
+
const manifestPath = path.join(cwd, 'env.manifest.json');
|
|
449
|
+
if (fs.existsSync(manifestPath)) {
|
|
450
|
+
console.log('env.manifest.json already exists \u2014 skipping');
|
|
451
|
+
} else {
|
|
452
|
+
generateManifest(cwd, projectType, found);
|
|
453
|
+
console.log('env.manifest.json \u2705 generated');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Install hook
|
|
457
|
+
const hookResult = installHook(cwd);
|
|
458
|
+
if (hookResult === 'already') {
|
|
459
|
+
console.log('git hook already installed \u2014 skipping');
|
|
460
|
+
} else if (hookResult === true) {
|
|
461
|
+
console.log('git hook \u2705 installed');
|
|
462
|
+
} else {
|
|
463
|
+
console.log('git hook \u26A0\uFE0F skipped (not a git repo)');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
console.log('');
|
|
467
|
+
console.log(DIVIDER);
|
|
468
|
+
console.log('');
|
|
469
|
+
console.log('\uD83D\uDEE1\uFE0F You\'re protected');
|
|
470
|
+
console.log('');
|
|
471
|
+
console.log(' safelaunch will validate your environment');
|
|
472
|
+
console.log(' automatically before every git push.');
|
|
473
|
+
console.log('');
|
|
474
|
+
console.log(' To validate manually at any time:');
|
|
475
|
+
console.log(' safelaunch validate');
|
|
476
|
+
console.log('');
|
|
477
|
+
|
|
478
|
+
await track('safelaunch_setup_run', {
|
|
479
|
+
project_type: projectType,
|
|
480
|
+
passed: true,
|
|
481
|
+
vars_found: found.size,
|
|
482
|
+
manifest_created: !fs.existsSync(manifestPath),
|
|
483
|
+
hook_installed: hookResult === true
|
|
484
|
+
});
|
|
485
|
+
await shutdown();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
setup();
|