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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safelaunch",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Validate your environment before every push. Catch missing, empty, and misconfigured env variables before they break production.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm version](https://badge.fury.io/js/safelaunch.svg)](https://www.npmjs.com/package/safelaunch)
6
6
 
7
- Run one command before you deploy. safelaunch scans your entire project and tells you exactly what will break — before it breaks in production.
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
- ## Try it now zero setup
15
+ ## Get protected in one command
16
16
 
17
17
  ```bash
18
- npx safelaunch scan
18
+ npx safelaunch setup
19
19
  ```
20
20
 
21
- No installation. No config. Just run it in any JavaScript project.
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
- Scanning your project...
26
+ Running safelaunch setup...
25
27
  Detected: Next.js
26
28
 
27
- 🚨 Safelaunch Scan Report
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
- What's working (5 checks passed)
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
- - Environment file detected
67
- - No hardcoded secrets found
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
- 💡 Next step
84
+ 🛡️ You're protected
75
85
 
76
- Run:
77
- safelaunch init
86
+ safelaunch will validate your environment
87
+ automatically before every git push.
78
88
 
79
- Lock this configuration and prevent future breakage
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
- ## Lock it in permanently
134
+ ## Individual commands
124
135
 
125
- Once scan shows you what's broken, lock your configuration so it never breaks again.
136
+ If you want to run steps individually:
126
137
 
127
- **Step 1Generate your environment manifest**
138
+ **Quick scanno 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
- Scans your codebase and creates an `env.manifest.json` — a contract file that defines exactly what your app needs to run.
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
- Checks your live environment against the manifest and tells you exactly what will break before it does.
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 scan');
26
- console.log(' safelaunch init');
27
- console.log(' safelaunch validate');
28
- console.log(' safelaunch hook install');
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
  }
@@ -99,11 +99,12 @@ function detectProjectType(cwd) {
99
99
  }
100
100
 
101
101
  function scanFiles(dir, extensions, pattern, found = new Set()) {
102
- const items = fs.readdirSync(dir);
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
- const stat = fs.statSync(full);
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
- const items = fs.readdirSync(dir);
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
- const stat = fs.statSync(full);
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();