@metaadri/secureauth 1.2.2 → 1.2.3

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
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "1.2.2",
6
+ "version": "1.2.3",
7
7
  "description": "SecureAuth CLI - Add enterprise authentication to any Express app in one command",
8
8
  "bin": {
9
9
  "secureauth": "bin/cli.js"
package/src/detector.js CHANGED
@@ -3,11 +3,13 @@ const path = require('path');
3
3
 
4
4
  const REQUIRED_DIRS = ['controllers', 'routes', 'middleware', 'models', 'utils', 'database'];
5
5
  const SECUREAUTH_DEPS = [
6
- 'express', 'bcryptjs', 'jsonwebtoken', 'helmet',
6
+ 'express', 'jsonwebtoken', 'helmet',
7
7
  'express-rate-limit', 'express-slow-down', 'speakeasy',
8
8
  'qrcode', 'dotenv', 'cors', 'uuid'
9
9
  ];
10
10
 
11
+ const BCRYPT_ALIASES = ['bcryptjs', 'bcrypt'];
12
+
11
13
  function detect(dir) {
12
14
  const report = {
13
15
  hasPackageJson: false,
@@ -49,7 +51,11 @@ function detect(dir) {
49
51
  } catch {}
50
52
  }
51
53
 
52
- report.missingDeps = SECUREAUTH_DEPS.filter(d => !(d in allDeps));
54
+ const hasBcrypt = BCRYPT_ALIASES.some(d => d in allDeps);
55
+ report.missingDeps = SECUREAUTH_DEPS.filter(d => {
56
+ if (d === 'bcryptjs') return !hasBcrypt;
57
+ return !(d in allDeps);
58
+ });
53
59
  }
54
60
 
55
61
  const existingDirs = [];
package/src/generator.js CHANGED
@@ -60,18 +60,78 @@ function buildEnvContent(answers) {
60
60
  return lines.join('\n');
61
61
  }
62
62
 
63
+ function mergeEnvContent(existingContent, newContent) {
64
+ const existingLines = existingContent.split('\n');
65
+ const existingKeys = new Set();
66
+ let existingDbUrl = null;
67
+
68
+ for (const line of existingLines) {
69
+ const eqIdx = line.indexOf('=');
70
+ if (eqIdx !== -1) {
71
+ const key = line.slice(0, eqIdx).trim();
72
+ existingKeys.add(key);
73
+ if (key === 'DATABASE_URL') {
74
+ existingDbUrl = line.slice(eqIdx + 1).trim();
75
+ }
76
+ }
77
+ }
78
+
79
+ const newLines = newContent.split('\n');
80
+ const merged = [];
81
+
82
+ if (existingDbUrl) {
83
+ for (const line of newLines) {
84
+ if (line.startsWith('DATABASE_URL=')) {
85
+ continue;
86
+ }
87
+ merged.push(line);
88
+ }
89
+ const dbSectionStart = merged.findIndex(l => l.includes('# Database'));
90
+ if (dbSectionStart !== -1) {
91
+ merged.splice(dbSectionStart, 0, `DATABASE_URL=${existingDbUrl}`);
92
+ } else {
93
+ merged.push('');
94
+ merged.push('# Database Configuration (preserved from existing .env)');
95
+ merged.push(`DATABASE_URL=${existingDbUrl}`);
96
+ }
97
+ } else {
98
+ merged.push(...newLines);
99
+ }
100
+
101
+ for (const key of existingKeys) {
102
+ if (key === 'DATABASE_URL') continue;
103
+ const existingLine = existingLines.find(l => l.startsWith(key + '='));
104
+ if (existingLine) {
105
+ const newIdx = merged.findIndex(l => l.startsWith(key + '='));
106
+ if (newIdx !== -1) {
107
+ merged[newIdx] = existingLine;
108
+ }
109
+ }
110
+ }
111
+
112
+ return merged.join('\n');
113
+ }
114
+
63
115
  function writeEnvFile(targetDir, answers) {
64
116
  const fs = require('fs');
65
117
  const path = require('path');
66
118
  const envPath = path.join(targetDir, '.env');
67
119
 
120
+ const newContent = buildEnvContent(answers);
121
+
68
122
  if (fs.existsSync(envPath)) {
69
- logger.warn('.env already exists — skipping (delete it first to regenerate)');
70
- return false;
123
+ const existingContent = fs.readFileSync(envPath, 'utf-8');
124
+ const merged = mergeEnvContent(existingContent, newContent);
125
+ if (merged !== existingContent) {
126
+ fs.writeFileSync(envPath, merged, 'utf-8');
127
+ logger.success('.env updated with SecureAuth settings (preserved existing values)');
128
+ } else {
129
+ logger.warn('.env already exists — settings are up to date');
130
+ }
131
+ return true;
71
132
  }
72
133
 
73
- const content = buildEnvContent(answers);
74
- fs.writeFileSync(envPath, content, 'utf-8');
134
+ fs.writeFileSync(envPath, newContent, 'utf-8');
75
135
  logger.success('.env created with secure defaults');
76
136
  return true;
77
137
  }
package/src/index.js CHANGED
@@ -6,6 +6,7 @@ const { printReport } = require('./reporter');
6
6
  const { install } = require('./installer');
7
7
  const { writeEnvFile } = require('./generator');
8
8
  const { scaffold } = require('./scaffolder');
9
+ const { patchExisting } = require('./patcher');
9
10
  const {
10
11
  askProjectType,
11
12
  askProjectName,
@@ -13,6 +14,7 @@ const {
13
14
  askPort,
14
15
  askJwtSecret,
15
16
  askFeatures,
17
+ askInstallMode,
16
18
  askConfirm
17
19
  } = require('./questions');
18
20
 
@@ -101,6 +103,12 @@ async function init(defaults) {
101
103
  logger.info(logger.dim('Not sure which features to pick? Visit https://secureauth106293.netlify.app/ for guidance.'));
102
104
  answers.features = await askFeatures(defaults);
103
105
 
106
+ if (projectType === 'existing') {
107
+ answers.installMode = await askInstallMode(defaults);
108
+ } else {
109
+ answers.installMode = 'scaffold';
110
+ }
111
+
104
112
  logger.info('');
105
113
  const confirmed = await askConfirm(defaults);
106
114
  if (!confirmed) {
@@ -113,16 +121,23 @@ async function init(defaults) {
113
121
  logger.info(`Database: ${answers.database}`);
114
122
  logger.info(`Port: ${answers.port}`);
115
123
  logger.info(`Features: ${answers.features.join(', ') || 'none'}`);
124
+ if (projectType === 'existing') {
125
+ logger.info(`Mode: ${answers.installMode === 'patch' ? 'Patch existing login route' : 'Add alongside existing auth'}`);
126
+ }
116
127
 
117
128
  logger.info('');
118
129
 
119
- await install(targetDir, report.missingDeps, answers);
120
-
121
- const filesCreated = scaffold(targetDir, answers);
122
-
123
- writeEnvFile(targetDir, answers);
124
-
125
- printSuccess(answers, filesCreated);
130
+ if (answers.installMode === 'patch') {
131
+ await install(targetDir, report.missingDeps, answers);
132
+ patchExisting(targetDir, answers);
133
+ writeEnvFile(targetDir, answers);
134
+ printSuccess(answers, 0);
135
+ } else {
136
+ await install(targetDir, report.missingDeps, answers);
137
+ const filesCreated = scaffold(targetDir, answers);
138
+ writeEnvFile(targetDir, answers);
139
+ printSuccess(answers, filesCreated);
140
+ }
126
141
  }
127
142
 
128
143
  module.exports = { init };
package/src/installer.js CHANGED
@@ -28,7 +28,7 @@ function install(cwd, missingDeps, answers) {
28
28
  const child = spawn('npm', args, {
29
29
  cwd,
30
30
  stdio: ['ignore', 'pipe', 'pipe'],
31
- shell: true
31
+ shell: false
32
32
  });
33
33
 
34
34
  let output = '';
package/src/patcher.js ADDED
@@ -0,0 +1,409 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { logger } = require('./logger');
4
+
5
+ const ENTRY_CANDIDATES = ['server.js', 'app.js', 'index.js'];
6
+ const NESTED_ENTRIES = [
7
+ ['backend', 'server.js'],
8
+ ['backend', 'app.js'],
9
+ ['backend', 'index.js'],
10
+ ['src', 'server.js'],
11
+ ['src', 'app.js'],
12
+ ['src', 'index.js']
13
+ ];
14
+ const AUTH_ROUTE_PATTERNS = [
15
+ ['routes', 'auth.js'],
16
+ ['routes', 'authRoutes.js'],
17
+ ['backend', 'routes', 'auth.js'],
18
+ ['backend', 'routes', 'authRoutes.js'],
19
+ ['src', 'routes', 'auth.js'],
20
+ ['src', 'routes', 'authRoutes.js']
21
+ ];
22
+
23
+ function findEntryPoint(targetDir) {
24
+ for (const name of ENTRY_CANDIDATES) {
25
+ const p = path.join(targetDir, name);
26
+ if (fs.existsSync(p)) return p;
27
+ }
28
+ for (const parts of NESTED_ENTRIES) {
29
+ const p = path.join(targetDir, ...parts);
30
+ if (fs.existsSync(p)) return p;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function findAuthRouteFile(targetDir) {
36
+ const entry = findEntryPoint(targetDir);
37
+ if (entry) {
38
+ const content = fs.readFileSync(entry, 'utf-8');
39
+ const regex = /require\(['"]\.\/?([^'"]*(?:auth|login)[^'"]*)['"]\)/i;
40
+ const m = content.match(regex);
41
+ if (m) {
42
+ let routePath = m[1].replace(/\.js$/, '');
43
+ const base = path.dirname(entry);
44
+ for (const ext of ['', '.js']) {
45
+ const full = path.resolve(base, routePath + ext);
46
+ if (fs.existsSync(full)) return full;
47
+ }
48
+ }
49
+ }
50
+ for (const parts of AUTH_ROUTE_PATTERNS) {
51
+ const p = path.join(targetDir, ...parts);
52
+ if (fs.existsSync(p)) return p;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function detectDatabaseType(targetDir) {
58
+ const pkgPath = path.join(targetDir, 'package.json');
59
+ if (fs.existsSync(pkgPath)) {
60
+ try {
61
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
62
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
63
+ if ('pg' in allDeps || '@neondatabase/serverless' in allDeps) return 'postgres';
64
+ if ('better-sqlite3' in allDeps || 'sql.js' in allDeps) return 'sqlite';
65
+ if ('mysql2' in allDeps) return 'mysql';
66
+ } catch {}
67
+ }
68
+ const entry = findEntryPoint(targetDir);
69
+ if (entry) {
70
+ try {
71
+ const content = fs.readFileSync(entry, 'utf-8');
72
+ if (/require\(['"](pg|@neondatabase\/serverless)['"]\)/.test(content)) return 'postgres';
73
+ if (/require\(['"]better-sqlite3['"]\)/.test(content)) return 'sqlite';
74
+ if (/require\(['"]mysql2['"]\)/.test(content)) return 'mysql';
75
+ } catch {}
76
+ }
77
+ const authFile = findAuthRouteFile(targetDir);
78
+ if (authFile) {
79
+ try {
80
+ const content = fs.readFileSync(authFile, 'utf-8');
81
+ if (/\$\d/.test(content)) return 'postgres';
82
+ if (/\?\s*\]/.test(content) && /better-sqlite3/.test(content)) return 'sqlite';
83
+ } catch {}
84
+ }
85
+ return 'sqlite';
86
+ }
87
+
88
+ function isAlreadyPatched(content) {
89
+ return /checkAccountLockout/.test(content) ||
90
+ /MAX_FAILED_ATTEMPTS/.test(content) ||
91
+ /failed_login_attempts/.test(content) ||
92
+ /LOCKOUT_DURATION_MINUTES/.test(content);
93
+ }
94
+
95
+ function detectDbVariable(content) {
96
+ const poolMatch = content.match(/(?:const|let|var)\s+(pool|db|client|query)\s*=\s*(?:new\s+(?:Pool|Client|Database)|require\()/i);
97
+ if (poolMatch) return poolMatch[1];
98
+ const exportMatch = content.match(/(?:const|let|var)\s+(\w+)\s*=\s*require\(['"]\.\.?\/[^'"]*db[^'"]*['"]\)/i);
99
+ if (exportMatch) return exportMatch[1];
100
+ return 'pool';
101
+ }
102
+
103
+ function detectParamStyle(content) {
104
+ if (/\$\d/.test(content)) return 'postgres';
105
+ return 'sqlite';
106
+ }
107
+
108
+ function generateLockoutEnvBlock() {
109
+ return `
110
+ const MAX_FAILED_ATTEMPTS = parseInt(process.env.MAX_FAILED_ATTEMPTS) || 5;
111
+ const LOCKOUT_DURATION_MINUTES = parseInt(process.env.LOCKOUT_DURATION_MINUTES) || 30;
112
+ `;
113
+ }
114
+
115
+ function generateHelperBlock() {
116
+ return `
117
+
118
+ async function checkAccountLockout(user) {
119
+ if (user.locked_until) {
120
+ const lockedUntil = new Date(user.locked_until);
121
+ if (lockedUntil > new Date()) {
122
+ const minutesLeft = Math.ceil((lockedUntil - new Date()) / 60000);
123
+ const err = new Error(\`Account locked due to too many failed attempts. Try again in \${minutesLeft} minute(s).\`);
124
+ err.statusCode = 423;
125
+ throw err;
126
+ }
127
+ }
128
+ }
129
+ `;
130
+ }
131
+
132
+ function generateLockoutCheckBlock() {
133
+ return `
134
+ try {
135
+ await checkAccountLockout(user);
136
+ } catch (lockErr) {
137
+ return res.status(lockErr.statusCode || 423).json({
138
+ error: 'Account locked',
139
+ message: lockErr.message
140
+ });
141
+ }
142
+ `;
143
+ }
144
+
145
+ function generateIncrementBlock(dbVar, paramStyle) {
146
+ if (paramStyle === 'postgres') {
147
+ return `
148
+ const failedResult = await ${dbVar}.query('UPDATE users SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = $1 WHERE id = $2 RETURNING failed_login_attempts', [new Date().toISOString(), user.id]);
149
+ const failedAttempts = failedResult.rows[0] ? failedResult.rows[0].failed_login_attempts : 1;
150
+ const remaining = MAX_FAILED_ATTEMPTS - failedAttempts;
151
+ if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
152
+ const lockedUntil = new Date();
153
+ lockedUntil.setMinutes(lockedUntil.getMinutes() + LOCKOUT_DURATION_MINUTES);
154
+ await ${dbVar}.query('UPDATE users SET locked_until = $1 WHERE id = $2', [lockedUntil.toISOString(), user.id]);
155
+ return res.status(423).json({
156
+ error: 'Account locked',
157
+ message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
158
+ });
159
+ }
160
+ `;
161
+ } else {
162
+ return `
163
+ ${dbVar}.query('UPDATE users SET failed_login_attempts = failed_login_attempts + 1, last_failed_login = ? WHERE id = ?', [new Date().toISOString(), user.id]);
164
+ const failedRow = ${dbVar}.query('SELECT failed_login_attempts FROM users WHERE id = ?', [user.id]);
165
+ const failedAttempts = failedRow.rows[0] ? failedRow.rows[0].failed_login_attempts : 1;
166
+ const remaining = MAX_FAILED_ATTEMPTS - failedAttempts;
167
+ if (failedAttempts >= MAX_FAILED_ATTEMPTS) {
168
+ const lockedUntil = new Date();
169
+ lockedUntil.setMinutes(lockedUntil.getMinutes() + LOCKOUT_DURATION_MINUTES);
170
+ ${dbVar}.query('UPDATE users SET locked_until = ? WHERE id = ?', [lockedUntil.toISOString(), user.id]);
171
+ return res.status(423).json({
172
+ error: 'Account locked',
173
+ message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
174
+ });
175
+ }
176
+ `;
177
+ }
178
+ }
179
+
180
+ function generateResetBlock(dbVar, paramStyle) {
181
+ const param = paramStyle === 'postgres' ? '$1' : '?';
182
+ return `
183
+ await ${dbVar}.query('UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_failed_login = NULL WHERE id = ${param}', [user.id]);
184
+ `;
185
+ }
186
+
187
+ function generateMigrationSql(dbType) {
188
+ if (dbType === 'postgres') {
189
+ return `-- SecureAuth: Add account lockout columns
190
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS failed_login_attempts INTEGER DEFAULT 0;
191
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS locked_until TEXT;
192
+ ALTER TABLE users ADD COLUMN IF NOT EXISTS last_failed_login TEXT;
193
+ `;
194
+ }
195
+ return `-- SecureAuth: Add account lockout columns
196
+ ALTER TABLE users ADD COLUMN failed_login_attempts INTEGER DEFAULT 0;
197
+ ALTER TABLE users ADD COLUMN locked_until TEXT;
198
+ ALTER TABLE users ADD COLUMN last_failed_login TEXT;
199
+ `;
200
+ }
201
+
202
+ function addEnvVars(content) {
203
+ const requireRegex = /(?:const|let|var)\s+\w+\s*=\s*require\([^)]+\)/g;
204
+ let lastMatch = null;
205
+ let m;
206
+ while ((m = requireRegex.exec(content)) !== null) {
207
+ lastMatch = m;
208
+ }
209
+ if (lastMatch) {
210
+ const insertPos = content.indexOf('\n', lastMatch.index);
211
+ const splitPos = insertPos !== -1 ? insertPos + 1 : content.length;
212
+ return {
213
+ content: content.slice(0, splitPos) + generateLockoutEnvBlock() + content.slice(splitPos),
214
+ modified: true
215
+ };
216
+ }
217
+ return { content: generateLockoutEnvBlock() + '\n' + content, modified: true };
218
+ }
219
+
220
+ function addHelper(content) {
221
+ const loginRouteRegex = /(?:router|app)\s*\.\s*(?:post|get)\s*\(\s*['"](?:\/api\/)?(?:login|signin)['"]/;
222
+ const loginMatch = content.match(loginRouteRegex);
223
+ if (!loginMatch) return { content, modified: false };
224
+
225
+ const lineStart = content.lastIndexOf('\n', loginMatch.index) + 1;
226
+ const helper = generateHelperBlock();
227
+ return {
228
+ content: content.slice(0, lineStart) + helper + '\n' + content.slice(lineStart),
229
+ modified: true
230
+ };
231
+ }
232
+
233
+ function addLockoutCheck(content, dbVar) {
234
+ const compareRegex = /(?:const\s+)?is(?:Valid|PasswordValid)\s*=\s*await\s+bcrypt\.compare\(/;
235
+ const compareMatch = content.match(compareRegex);
236
+ if (!compareMatch) return { content, modified: false };
237
+
238
+ const lineStart = content.lastIndexOf('\n', compareMatch.index) + 1;
239
+ const lockoutCheck = generateLockoutCheckBlock();
240
+ return {
241
+ content: content.slice(0, lineStart) + lockoutCheck + content.slice(lineStart),
242
+ modified: true
243
+ };
244
+ }
245
+
246
+ function addIncrementReset(content, dbVar, paramStyle) {
247
+ const invalidPwMatch = content.match(/if\s*\(\s*!is(?:Valid|PasswordValid)\s*\)/);
248
+ if (!invalidPwMatch) return { content, modified: false };
249
+
250
+ const blockStart = content.indexOf('{', invalidPwMatch.index);
251
+ if (blockStart === -1) return { content, modified: false };
252
+
253
+ const incrementBlock = generateIncrementBlock(dbVar, paramStyle);
254
+
255
+ const afterBrace = content.slice(blockStart + 1);
256
+ const firstReturn = afterBrace.search(/\breturn\s+res\.status\(401\)/);
257
+ if (firstReturn === -1) return { content, modified: false };
258
+
259
+ const insertPos = blockStart + 1 + firstReturn;
260
+ content = content.slice(0, insertPos) + incrementBlock + content.slice(insertPos);
261
+
262
+ const closingBraceIndex = findMatchingBrace(content, blockStart);
263
+ if (closingBraceIndex === -1) return { content, modified: false };
264
+
265
+ const afterInvalidBlock = content.slice(closingBraceIndex + 1);
266
+ const successPwMatch = afterInvalidBlock.match(/if\s*\(!is(?:Valid|PasswordValid)\)/);
267
+ if (successPwMatch) {
268
+ const secondBlockStart = afterInvalidBlock.indexOf('{', successPwMatch.index);
269
+ if (secondBlockStart !== -1) {
270
+ const secondClosing = findMatchingBrace(afterInvalidBlock, secondBlockStart);
271
+ if (secondClosing !== -1) {
272
+ const afterSecond = afterInvalidBlock.slice(secondClosing + 1);
273
+ const resetBlock = generateResetBlock(dbVar, paramStyle);
274
+ const successMatch = afterSecond.match(/\breturn\s+res\.status\(2\d{2}\)/);
275
+ if (successMatch) {
276
+ const insertAfterSecond = closingBraceIndex + 1 + secondClosing + 1 + successMatch.index;
277
+ content = content.slice(0, insertAfterSecond) + resetBlock + content.slice(insertAfterSecond);
278
+ return { content, modified: true };
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ const afterInvalidBlockClean = content.slice(closingBraceIndex + 1);
285
+ const awaitCompareMatch = afterInvalidBlockClean.match(/await\s+bcrypt\.compare\(/);
286
+ if (awaitCompareMatch) {
287
+ return { content, modified: false };
288
+ }
289
+
290
+ const resetBlock = generateResetBlock(dbVar, paramStyle);
291
+ const successMatch = afterInvalidBlockClean.match(/\breturn\s+res\.status\(2\d{2}\)/);
292
+ if (successMatch) {
293
+ const insertPos3 = closingBraceIndex + 1 + successMatch.index;
294
+ content = content.slice(0, insertPos3) + resetBlock + content.slice(insertPos3);
295
+ return { content, modified: true };
296
+ }
297
+
298
+ return { content, modified: false };
299
+ }
300
+
301
+ function addStatus423Return(content, dbVar, paramStyle) {
302
+ const lockedResponse = `\n return res.status(423).json({
303
+ error: 'Account locked',
304
+ message: \`Account locked due to \${MAX_FAILED_ATTEMPTS} failed attempts. Try again in \${LOCKOUT_DURATION_MINUTES} minutes.\`
305
+ });`;
306
+ const ifBlockRegex = /if\s*\(\s*failedAttempts\s*>=\s*MAX_FAILED_ATTEMPTS\s*\)\s*\{/;
307
+ const ifMatch = content.match(ifBlockRegex);
308
+ if (!ifMatch) return { content, modified: false };
309
+
310
+ const blockStart = content.indexOf('{', ifMatch.index);
311
+ const beforeLock = content.slice(blockStart + 1).match(/\bawait\s+dbVar\.query\(/);
312
+ if (beforeLock) {
313
+ const insertAfterLock = blockStart + 1 + beforeLock.index + beforeLock[0].length;
314
+ const semicolon = content.indexOf(';', insertAfterLock);
315
+ if (semicolon !== -1) {
316
+ content = content.slice(0, semicolon + 1) + lockedResponse + content.slice(semicolon + 1);
317
+ return { content, modified: true };
318
+ }
319
+ }
320
+ return { content, modified: false };
321
+ }
322
+
323
+ function findMatchingBrace(str, openPos) {
324
+ if (str[openPos] !== '{') return -1;
325
+ let depth = 0;
326
+ for (let i = openPos; i < str.length; i++) {
327
+ if (str[i] === '{') depth++;
328
+ else if (str[i] === '}') {
329
+ depth--;
330
+ if (depth === 0) return i;
331
+ }
332
+ }
333
+ return -1;
334
+ }
335
+
336
+ function writeMigration(targetDir, dbType) {
337
+ const migrationsDir = path.join(targetDir, 'migrations');
338
+ if (!fs.existsSync(migrationsDir)) {
339
+ fs.mkdirSync(migrationsDir, { recursive: true });
340
+ }
341
+
342
+ const timestamp = new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14);
343
+ const fileName = `${timestamp}_add_account_lockout.sql`;
344
+ const filePath = path.join(migrationsDir, fileName);
345
+
346
+ if (fs.existsSync(filePath)) {
347
+ logger.warn(`Migration ${fileName} already exists — skipping`);
348
+ return null;
349
+ }
350
+
351
+ const sql = generateMigrationSql(dbType);
352
+ fs.writeFileSync(filePath, sql, 'utf-8');
353
+ logger.success(`Created migration: migrations/${fileName}`);
354
+ return filePath;
355
+ }
356
+
357
+ function patchExisting(targetDir, answers) {
358
+ const hasLockout = answers.features && answers.features.includes('accountLockout');
359
+ if (!hasLockout) {
360
+ logger.info('Account lockout not selected — skipping patcher');
361
+ return { patched: false };
362
+ }
363
+
364
+ const authFile = findAuthRouteFile(targetDir);
365
+ if (!authFile) {
366
+ logger.warn('Could not find existing auth route file — lockout not patched');
367
+ return { patched: false };
368
+ }
369
+
370
+ logger.info(`Found auth route: ${path.relative(targetDir, authFile)}`);
371
+
372
+ let content = fs.readFileSync(authFile, 'utf-8');
373
+ if (isAlreadyPatched(content)) {
374
+ logger.info('Auth route already has lockout protection');
375
+ const migrationPath = writeMigration(targetDir, detectDatabaseType(targetDir));
376
+ return { patched: true, alreadyPatched: true, authFile, migrationPath };
377
+ }
378
+
379
+ const dbType = detectDatabaseType(targetDir);
380
+ const paramStyle = detectParamStyle(content);
381
+ const dbVar = detectDbVariable(content);
382
+
383
+ logger.info(`Database type: ${dbType}, param style: ${paramStyle}, DB variable: \`${dbVar}\``);
384
+
385
+ let result = addEnvVars(content);
386
+ content = result.content;
387
+
388
+ result = addHelper(content);
389
+ content = result.content;
390
+
391
+ result = addLockoutCheck(content, dbVar);
392
+ content = result.content;
393
+
394
+ result = addIncrementReset(content, dbVar, paramStyle);
395
+ content = result.content;
396
+
397
+ fs.writeFileSync(authFile, content, 'utf-8');
398
+ logger.success(`Patched ${path.relative(targetDir, authFile)} with account lockout`);
399
+
400
+ const migrationPath = writeMigration(targetDir, dbType);
401
+
402
+ logger.info('Add these to your .env file for lockout configuration:');
403
+ logger.info(' MAX_FAILED_ATTEMPTS=5');
404
+ logger.info(' LOCKOUT_DURATION_MINUTES=30');
405
+
406
+ return { patched: true, authFile, migrationPath, dbType };
407
+ }
408
+
409
+ module.exports = { patchExisting, findEntryPoint, findAuthRouteFile, detectDatabaseType, detectDbVariable, detectParamStyle, isAlreadyPatched, writeMigration };
package/src/questions.js CHANGED
@@ -98,6 +98,21 @@ async function askFeatures(defaults) {
98
98
  return features;
99
99
  }
100
100
 
101
+ async function askInstallMode(defaults) {
102
+ if (defaults) return 'patch';
103
+ const { mode } = await prompt({
104
+ type: 'select',
105
+ name: 'mode',
106
+ message: 'How would you like to install SecureAuth?',
107
+ choices: [
108
+ { name: 'patch', message: 'Patch existing login route with account lockout (Recommended)' },
109
+ { name: 'alongside', message: 'Add alongside existing auth (mount at /api/secure-auth)' }
110
+ ],
111
+ initial: 0
112
+ });
113
+ return mode;
114
+ }
115
+
101
116
  async function askConfirm(defaults) {
102
117
  if (defaults) return true;
103
118
  const { confirmed } = await prompt({
@@ -117,5 +132,6 @@ module.exports = {
117
132
  askPort,
118
133
  askJwtSecret,
119
134
  askFeatures,
135
+ askInstallMode,
120
136
  askConfirm
121
137
  };
package/src/scaffolder.js CHANGED
@@ -121,6 +121,40 @@ function scaffold(targetDir, answers) {
121
121
  return fileCount;
122
122
  }
123
123
 
124
+ function postProcessExisting(targetDir) {
125
+ const serverFiles = ['server.js', 'app.js', 'index.js'];
126
+ let entryContent = null;
127
+ let entryPath = null;
128
+
129
+ for (const f of serverFiles) {
130
+ const p = path.join(targetDir, f);
131
+ if (fs.existsSync(p)) {
132
+ entryContent = fs.readFileSync(p, 'utf-8');
133
+ entryPath = p;
134
+ break;
135
+ }
136
+ }
137
+ if (!entryContent) return;
138
+
139
+ const mods = [];
140
+
141
+ const dotenvRegex = /require\(['"]dotenv['"]\)\.config\(\);/;
142
+ if (!dotenvRegex.test(entryContent) && !entryContent.includes("require('dotenv')")) {
143
+ const firstRequireEnd = entryContent.indexOf(';') + 1;
144
+ if (firstRequireEnd > 0) {
145
+ mods.push({ pos: 0, end: firstRequireEnd, insert: `const path = require('path');\nrequire('dotenv').config({ path: path.join(__dirname, '.env') });\n` });
146
+ }
147
+ }
148
+
149
+ if (mods.length > 0) {
150
+ for (const m of mods.reverse()) {
151
+ entryContent = entryContent.slice(0, m.end) + '\n' + m.insert + entryContent.slice(m.end);
152
+ }
153
+ fs.writeFileSync(entryPath, entryContent, 'utf-8');
154
+ logger.info(`Patched ${path.basename(entryPath)} with dotenv config`);
155
+ }
156
+ }
157
+
124
158
  function postProcess(targetDir) {
125
159
  const files = {
126
160
  'controllers/authController.js': [
@@ -170,6 +204,8 @@ function postProcess(targetDir) {
170
204
  // skip unreadable files
171
205
  }
172
206
  }
207
+
208
+ postProcessExisting(targetDir);
173
209
  }
174
210
 
175
211
  function collectFiles(dir) {