forgelens 0.1.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/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/dist/cli.cjs +1246 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1223 -0
- package/package.json +52 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1223 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/clean.ts
|
|
7
|
+
import { readdir, rm } from "fs/promises";
|
|
8
|
+
import { resolve } from "path";
|
|
9
|
+
import readline from "readline/promises";
|
|
10
|
+
import { stdin, stdout } from "process";
|
|
11
|
+
async function runClean(options) {
|
|
12
|
+
const rootPath = resolve(options.root);
|
|
13
|
+
const targetPath = resolve(rootPath, options.outDir);
|
|
14
|
+
ensureSafeDeletePath(rootPath, targetPath);
|
|
15
|
+
const planned = await listPathsToRemove(targetPath);
|
|
16
|
+
const log = options.logger?.log ?? console.log;
|
|
17
|
+
log(`Target output folder: ${targetPath}`);
|
|
18
|
+
log("Planned removal:");
|
|
19
|
+
if (planned.length === 0) {
|
|
20
|
+
log("- (nothing; folder does not exist)");
|
|
21
|
+
return { targetPath, removed: false, planned };
|
|
22
|
+
}
|
|
23
|
+
for (const item of planned) {
|
|
24
|
+
log(`- ${item}`);
|
|
25
|
+
}
|
|
26
|
+
const confirmed = options.yes || await askForConfirmation();
|
|
27
|
+
if (!confirmed) {
|
|
28
|
+
log("Clean canceled.");
|
|
29
|
+
return { targetPath, removed: false, planned };
|
|
30
|
+
}
|
|
31
|
+
await rm(targetPath, { recursive: true, force: true });
|
|
32
|
+
log("Clean complete.");
|
|
33
|
+
return { targetPath, removed: true, planned };
|
|
34
|
+
}
|
|
35
|
+
function ensureSafeDeletePath(rootPath, targetPath) {
|
|
36
|
+
if (targetPath === "/" || targetPath === rootPath) {
|
|
37
|
+
throw new Error("Refusing to delete root path.");
|
|
38
|
+
}
|
|
39
|
+
if (!targetPath.startsWith(`${rootPath}/`)) {
|
|
40
|
+
throw new Error("Refusing to delete outside the selected root folder.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function listPathsToRemove(targetPath) {
|
|
44
|
+
const exists = await pathExists(targetPath);
|
|
45
|
+
if (!exists) {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
const items = [targetPath];
|
|
49
|
+
await walk(targetPath, items);
|
|
50
|
+
return items;
|
|
51
|
+
}
|
|
52
|
+
async function walk(path, items) {
|
|
53
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
const fullPath = `${path}/${entry.name}`;
|
|
56
|
+
items.push(fullPath);
|
|
57
|
+
if (entry.isDirectory()) {
|
|
58
|
+
await walk(fullPath, items);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function askForConfirmation() {
|
|
63
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
64
|
+
try {
|
|
65
|
+
const answer = await rl.question("Confirm clean? Type 'yes' to continue: ");
|
|
66
|
+
return answer.trim().toLowerCase() === "yes";
|
|
67
|
+
} finally {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function pathExists(path) {
|
|
72
|
+
try {
|
|
73
|
+
await readdir(path);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/doctor.ts
|
|
81
|
+
import { access as access2 } from "fs/promises";
|
|
82
|
+
import { resolve as resolve2 } from "path";
|
|
83
|
+
import fg2 from "fast-glob";
|
|
84
|
+
|
|
85
|
+
// src/detectors/project.ts
|
|
86
|
+
import { access } from "fs/promises";
|
|
87
|
+
import { join } from "path";
|
|
88
|
+
|
|
89
|
+
// src/utils/fs.ts
|
|
90
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
91
|
+
import { dirname } from "path";
|
|
92
|
+
async function readTextIfExists(path) {
|
|
93
|
+
try {
|
|
94
|
+
return await readFile(path, "utf8");
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function readJsonIfExists(path) {
|
|
100
|
+
const text = await readTextIfExists(path);
|
|
101
|
+
if (!text) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(text);
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function writeText(path, content) {
|
|
111
|
+
await mkdir(dirname(path), { recursive: true });
|
|
112
|
+
await writeFile(path, content, "utf8");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/detectors/project.ts
|
|
116
|
+
async function detectProject(root) {
|
|
117
|
+
const packageJsonPath = join(root, "package.json");
|
|
118
|
+
const pkg = await readJsonIfExists(packageJsonPath);
|
|
119
|
+
const dependencies = Object.keys(pkg?.dependencies ?? {}).sort();
|
|
120
|
+
const devDependencies = Object.keys(pkg?.devDependencies ?? {}).sort();
|
|
121
|
+
const allDeps = /* @__PURE__ */ new Set([...dependencies, ...devDependencies]);
|
|
122
|
+
const framework = allDeps.has("next") ? "nextjs" : "unknown";
|
|
123
|
+
const language = await fileExists(join(root, "tsconfig.json")) ? "typescript" : "javascript";
|
|
124
|
+
const packageManager = await detectPackageManager(root);
|
|
125
|
+
return {
|
|
126
|
+
framework,
|
|
127
|
+
language,
|
|
128
|
+
packageManager,
|
|
129
|
+
scripts: pkg?.scripts ?? {},
|
|
130
|
+
dependencies,
|
|
131
|
+
devDependencies
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function detectPackageManager(root) {
|
|
135
|
+
if (await fileExists(join(root, "pnpm-lock.yaml"))) {
|
|
136
|
+
return "pnpm";
|
|
137
|
+
}
|
|
138
|
+
if (await fileExists(join(root, "yarn.lock"))) {
|
|
139
|
+
return "yarn";
|
|
140
|
+
}
|
|
141
|
+
if (await fileExists(join(root, "bun.lockb"))) {
|
|
142
|
+
return "bun";
|
|
143
|
+
}
|
|
144
|
+
if (await fileExists(join(root, "package-lock.json"))) {
|
|
145
|
+
return "npm";
|
|
146
|
+
}
|
|
147
|
+
return "unknown";
|
|
148
|
+
}
|
|
149
|
+
async function fileExists(path) {
|
|
150
|
+
try {
|
|
151
|
+
await access(path);
|
|
152
|
+
return true;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/detectors/security.ts
|
|
159
|
+
import fg from "fast-glob";
|
|
160
|
+
import { join as join2 } from "path";
|
|
161
|
+
|
|
162
|
+
// src/utils/ignore.ts
|
|
163
|
+
var DEFAULT_IGNORED_DIRS = [
|
|
164
|
+
"node_modules",
|
|
165
|
+
".next",
|
|
166
|
+
"dist",
|
|
167
|
+
"build",
|
|
168
|
+
".git",
|
|
169
|
+
"coverage",
|
|
170
|
+
"vendor",
|
|
171
|
+
"generated"
|
|
172
|
+
];
|
|
173
|
+
function defaultIgnores(outDir) {
|
|
174
|
+
const normalizedOutDir = normalizeOutDir(outDir);
|
|
175
|
+
const core = DEFAULT_IGNORED_DIRS.flatMap((dir) => [
|
|
176
|
+
`${dir}/**`,
|
|
177
|
+
`**/${dir}/**`
|
|
178
|
+
]);
|
|
179
|
+
return normalizedOutDir ? [...core, `${normalizedOutDir}/**`, `**/${normalizedOutDir}/**`] : core;
|
|
180
|
+
}
|
|
181
|
+
function normalizeOutDir(outDir) {
|
|
182
|
+
const trimmed = outDir.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
183
|
+
return trimmed.replace(/^\/+|\/+$/g, "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/detectors/security.ts
|
|
187
|
+
async function detectSecurity(root, outDir) {
|
|
188
|
+
const ignore = defaultIgnores(outDir);
|
|
189
|
+
const [envFiles, middlewareFiles, codeFiles] = await Promise.all([
|
|
190
|
+
fg([".env*", "**/.env*"], { cwd: root, ignore }),
|
|
191
|
+
fg(["middleware.@(ts|tsx|js|jsx)", "src/middleware.@(ts|tsx|js|jsx)"], {
|
|
192
|
+
cwd: root,
|
|
193
|
+
ignore
|
|
194
|
+
}),
|
|
195
|
+
fg(["**/*.@(ts|tsx|js|jsx)"], { cwd: root, ignore })
|
|
196
|
+
]);
|
|
197
|
+
const riskyFiles = [];
|
|
198
|
+
const sensitiveFiles = [];
|
|
199
|
+
let envUsageCount = 0;
|
|
200
|
+
for (const file of codeFiles) {
|
|
201
|
+
const text = await readTextIfExists(join2(root, file));
|
|
202
|
+
if (!text) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (text.includes("process.env")) {
|
|
206
|
+
envUsageCount += 1;
|
|
207
|
+
}
|
|
208
|
+
if (text.includes("eval(") || text.includes("dangerouslySetInnerHTML") || text.includes("child_process")) {
|
|
209
|
+
riskyFiles.push(file);
|
|
210
|
+
}
|
|
211
|
+
if (isSensitiveFile(file, text)) {
|
|
212
|
+
sensitiveFiles.push(file);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const notes = [];
|
|
216
|
+
notes.push(`process.env usage files: ${envUsageCount}`);
|
|
217
|
+
notes.push(
|
|
218
|
+
middlewareFiles.length > 0 ? "middleware detected" : "no middleware detected (unknown route protection coverage)"
|
|
219
|
+
);
|
|
220
|
+
if (envFiles.length === 0) {
|
|
221
|
+
notes.push("no .env files detected");
|
|
222
|
+
}
|
|
223
|
+
if (sensitiveFiles.length > 0) {
|
|
224
|
+
const sample = uniqueSorted(sensitiveFiles).slice(0, 8).join(", ");
|
|
225
|
+
notes.push(`security-sensitive files detected: ${sample}`);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
envFiles: uniqueSorted(envFiles),
|
|
229
|
+
middlewareFiles: uniqueSorted(middlewareFiles),
|
|
230
|
+
riskyFiles: uniqueSorted(riskyFiles),
|
|
231
|
+
sensitiveFiles: uniqueSorted(sensitiveFiles),
|
|
232
|
+
notes
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function isSensitiveFile(file, text) {
|
|
236
|
+
const lowerFile = file.toLowerCase();
|
|
237
|
+
if (lowerFile.includes("admin") || lowerFile.includes("auth") || lowerFile.includes("middleware") || lowerFile.includes("/actions.") || lowerFile.includes("/api/")) {
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
if (text.includes('"use server"') || text.includes("'use server'")) {
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
function uniqueSorted(values) {
|
|
246
|
+
return [...new Set(values)].sort();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/doctor.ts
|
|
250
|
+
async function inspectRepoSafety(options) {
|
|
251
|
+
const rootPath = resolve2(options.root);
|
|
252
|
+
const outputFolderPath = resolve2(rootPath, options.outDir);
|
|
253
|
+
const rootExists = await pathExists2(rootPath);
|
|
254
|
+
if (!rootExists) {
|
|
255
|
+
return {
|
|
256
|
+
rootPath,
|
|
257
|
+
rootExists: false,
|
|
258
|
+
packageJsonExists: false,
|
|
259
|
+
framework: "unknown",
|
|
260
|
+
packageManager: "unknown",
|
|
261
|
+
ignoredFoldersConfigured: defaultIgnores(options.outDir).length > 0,
|
|
262
|
+
outputFolderPath,
|
|
263
|
+
outputPathValid: isValidOutputPath(rootPath, outputFolderPath),
|
|
264
|
+
sourceRepoWillNotBeModified: true,
|
|
265
|
+
networkOrApiRequired: false,
|
|
266
|
+
envFiles: [],
|
|
267
|
+
scannableSourceFiles: 0,
|
|
268
|
+
warnings: ["Root path does not exist."]
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const packageJsonExists = await pathExists2(resolve2(rootPath, "package.json"));
|
|
272
|
+
const project = await detectProject(rootPath);
|
|
273
|
+
const security = await detectSecurity(rootPath, options.outDir);
|
|
274
|
+
const scannableSourceFiles = await detectScannableSourceFiles(rootPath, options.outDir);
|
|
275
|
+
const warnings = [];
|
|
276
|
+
if (!packageJsonExists) {
|
|
277
|
+
warnings.push("No package.json found at root. Check if --root points to the real project root.");
|
|
278
|
+
}
|
|
279
|
+
if (scannableSourceFiles === 0) {
|
|
280
|
+
warnings.push("No scannable source files found after ignore rules.");
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
rootPath,
|
|
284
|
+
rootExists,
|
|
285
|
+
packageJsonExists,
|
|
286
|
+
framework: project.framework,
|
|
287
|
+
packageManager: project.packageManager,
|
|
288
|
+
ignoredFoldersConfigured: defaultIgnores(options.outDir).length > 0,
|
|
289
|
+
outputFolderPath,
|
|
290
|
+
outputPathValid: isValidOutputPath(rootPath, outputFolderPath),
|
|
291
|
+
sourceRepoWillNotBeModified: true,
|
|
292
|
+
networkOrApiRequired: false,
|
|
293
|
+
envFiles: security.envFiles,
|
|
294
|
+
scannableSourceFiles,
|
|
295
|
+
warnings
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function renderDoctorReport(report) {
|
|
299
|
+
const lines = [
|
|
300
|
+
"ForgeLens Doctor",
|
|
301
|
+
`- Root path exists: ${status(report.rootExists)}`,
|
|
302
|
+
`- package.json exists: ${status(report.packageJsonExists)}`,
|
|
303
|
+
`- Detected framework: ${report.framework}`,
|
|
304
|
+
`- Detected package manager: ${report.packageManager}`,
|
|
305
|
+
`- Ignored folders configured: ${status(report.ignoredFoldersConfigured)}`,
|
|
306
|
+
`- Output folder path: ${report.outputFolderPath}`,
|
|
307
|
+
`- Output folder path valid: ${status(report.outputPathValid)}`,
|
|
308
|
+
`- Source repo will not be modified: ${status(report.sourceRepoWillNotBeModified)}`,
|
|
309
|
+
`- Network/API usage needed: ${report.networkOrApiRequired ? "yes" : "no"}`,
|
|
310
|
+
`- Scannable source files: ${report.scannableSourceFiles}`
|
|
311
|
+
];
|
|
312
|
+
if (report.envFiles.length > 0) {
|
|
313
|
+
lines.push("- .env files found:");
|
|
314
|
+
for (const file of report.envFiles) {
|
|
315
|
+
lines.push(` - ${file}`);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
lines.push("- .env files found: none");
|
|
319
|
+
}
|
|
320
|
+
lines.push("- Secret values printed: no");
|
|
321
|
+
if (report.warnings.length > 0) {
|
|
322
|
+
lines.push("- Warnings:");
|
|
323
|
+
for (const warning of report.warnings) {
|
|
324
|
+
lines.push(` - ${warning}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
|
329
|
+
function status(ok) {
|
|
330
|
+
return ok ? "ok" : "no";
|
|
331
|
+
}
|
|
332
|
+
async function pathExists2(path) {
|
|
333
|
+
try {
|
|
334
|
+
await access2(path);
|
|
335
|
+
return true;
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function isValidOutputPath(root, output) {
|
|
341
|
+
if (!output || output === root || output === "/") {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
return output.startsWith(`${root}/`);
|
|
345
|
+
}
|
|
346
|
+
async function detectScannableSourceFiles(root, outDir) {
|
|
347
|
+
const files = await fg2(["**/*.@(ts|tsx|js|jsx)"], {
|
|
348
|
+
cwd: root,
|
|
349
|
+
ignore: defaultIgnores(outDir)
|
|
350
|
+
});
|
|
351
|
+
return files.length;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// src/prompt.ts
|
|
355
|
+
function buildCodexPrompt(outDir = ".forgelens") {
|
|
356
|
+
return `Use \`${outDir}/FORGE_CONTEXT.md\`, \`${outDir}/ARCHITECTURE_MAP.md\`, \`${outDir}/SECURITY_RULES.md\`, and \`${outDir}/RISK_REPORT.md\` as repo context before editing.`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// src/scan.ts
|
|
360
|
+
import { isAbsolute, relative, resolve as resolve3 } from "path";
|
|
361
|
+
|
|
362
|
+
// src/detectors/auth.ts
|
|
363
|
+
import fg3 from "fast-glob";
|
|
364
|
+
import { join as join3 } from "path";
|
|
365
|
+
async function detectAuth(root, outDir) {
|
|
366
|
+
const ignore = defaultIgnores(outDir);
|
|
367
|
+
const [codeFiles, authFiles, middlewareFiles, envFiles, pkg] = await Promise.all([
|
|
368
|
+
fg3(["**/*.@(ts|tsx|js|jsx|mjs|cjs)"], { cwd: root, ignore }),
|
|
369
|
+
fg3(["**/*auth*.@(ts|tsx|js|jsx|mjs|cjs)", "app/**/auth.@(ts|tsx|js|jsx)"], {
|
|
370
|
+
cwd: root,
|
|
371
|
+
ignore
|
|
372
|
+
}),
|
|
373
|
+
fg3(["middleware.@(ts|tsx|js|jsx)", "src/middleware.@(ts|tsx|js|jsx)"], {
|
|
374
|
+
cwd: root,
|
|
375
|
+
ignore
|
|
376
|
+
}),
|
|
377
|
+
fg3([".env*", "**/.env*"], { cwd: root, ignore }),
|
|
378
|
+
readJsonIfExists(join3(root, "package.json"))
|
|
379
|
+
]);
|
|
380
|
+
const deps = /* @__PURE__ */ new Set([
|
|
381
|
+
...Object.keys(pkg?.dependencies ?? {}),
|
|
382
|
+
...Object.keys(pkg?.devDependencies ?? {})
|
|
383
|
+
]);
|
|
384
|
+
const codeIndex = await indexCode(root, codeFiles);
|
|
385
|
+
const providers = [];
|
|
386
|
+
pushProvider(providers, detectProvider("clerk", deps.has("@clerk/nextjs"), findFiles(codeIndex, /clerk/i), "Clerk dependency or files"));
|
|
387
|
+
pushProvider(
|
|
388
|
+
providers,
|
|
389
|
+
detectProvider(
|
|
390
|
+
"nextauth-authjs",
|
|
391
|
+
deps.has("next-auth") || deps.has("@auth/core") || deps.has("@auth/nextjs"),
|
|
392
|
+
findFiles(codeIndex, /next-auth|\bNextAuth\b|@auth\//),
|
|
393
|
+
"Auth.js/NextAuth dependency or imports"
|
|
394
|
+
)
|
|
395
|
+
);
|
|
396
|
+
pushProvider(
|
|
397
|
+
providers,
|
|
398
|
+
detectProvider(
|
|
399
|
+
"supabase-auth",
|
|
400
|
+
deps.has("@supabase/supabase-js"),
|
|
401
|
+
findFiles(codeIndex, /supabase\.auth|@supabase\/supabase-js|supabase/i),
|
|
402
|
+
"Supabase auth SDK usage"
|
|
403
|
+
)
|
|
404
|
+
);
|
|
405
|
+
pushProvider(
|
|
406
|
+
providers,
|
|
407
|
+
detectProvider(
|
|
408
|
+
"firebase-auth",
|
|
409
|
+
deps.has("firebase") || deps.has("firebase-admin") || deps.has("@google-cloud/firestore"),
|
|
410
|
+
findFiles(codeIndex, /firebase|firestore|getAuth\(|admin\.auth\(/i),
|
|
411
|
+
"Firebase auth SDK usage"
|
|
412
|
+
)
|
|
413
|
+
);
|
|
414
|
+
pushProvider(
|
|
415
|
+
providers,
|
|
416
|
+
detectProvider("lucia", deps.has("lucia"), findFiles(codeIndex, /lucia/i), "Lucia dependency or usage")
|
|
417
|
+
);
|
|
418
|
+
pushProvider(
|
|
419
|
+
providers,
|
|
420
|
+
detectProvider("better-auth", deps.has("better-auth"), findFiles(codeIndex, /better-auth/i), "Better Auth dependency or usage")
|
|
421
|
+
);
|
|
422
|
+
const jwtFiles = findFiles(codeIndex, /\bjwt\b|jsonwebtoken|jose|sign\(|verify\(/i);
|
|
423
|
+
if (jwtFiles.length > 0) {
|
|
424
|
+
providers.push({
|
|
425
|
+
name: "jwt-custom-auth",
|
|
426
|
+
confidence: deps.has("jsonwebtoken") || deps.has("jose") ? "high" : "medium",
|
|
427
|
+
evidenceFiles: jwtFiles.slice(0, 12),
|
|
428
|
+
notes: ["JWT-like auth patterns detected"]
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
const cookieSessionFiles = findFiles(codeIndex, /cookies\(|cookie|session|iron-session|next-session/i);
|
|
432
|
+
if (cookieSessionFiles.length > 0) {
|
|
433
|
+
providers.push({
|
|
434
|
+
name: "cookie-session-custom-auth",
|
|
435
|
+
confidence: deps.has("iron-session") || deps.has("next-session") ? "high" : "medium",
|
|
436
|
+
evidenceFiles: cookieSessionFiles.slice(0, 12),
|
|
437
|
+
notes: ["Cookie/session auth patterns detected"]
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (middlewareFiles.length > 0) {
|
|
441
|
+
providers.push({
|
|
442
|
+
name: "middleware-based-auth",
|
|
443
|
+
confidence: "medium",
|
|
444
|
+
evidenceFiles: middlewareFiles.slice(0, 12),
|
|
445
|
+
notes: ["Middleware files detected"]
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
const mergedProviders = uniqueSignals(providers);
|
|
449
|
+
const hasStrongProvider = mergedProviders.some((provider) => provider.name !== "middleware-based-auth");
|
|
450
|
+
if (!hasStrongProvider && authFiles.length > 0) {
|
|
451
|
+
mergedProviders.push({
|
|
452
|
+
name: "custom-auth",
|
|
453
|
+
confidence: "low",
|
|
454
|
+
evidenceFiles: authFiles.slice(0, 12),
|
|
455
|
+
notes: ["Auth-related files found without a clear provider"]
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
if (mergedProviders.length === 0) {
|
|
459
|
+
mergedProviders.push({
|
|
460
|
+
name: "unknown",
|
|
461
|
+
confidence: "low",
|
|
462
|
+
evidenceFiles: [],
|
|
463
|
+
notes: ["No auth provider signal detected"]
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
providers: uniqueSignals(mergedProviders),
|
|
468
|
+
files: uniqueSorted2(authFiles),
|
|
469
|
+
evidenceFiles: uniqueSorted2([
|
|
470
|
+
...authFiles,
|
|
471
|
+
...middlewareFiles,
|
|
472
|
+
...envFiles,
|
|
473
|
+
...mergedProviders.flatMap((provider) => provider.evidenceFiles)
|
|
474
|
+
]),
|
|
475
|
+
middlewareFiles: uniqueSorted2(middlewareFiles),
|
|
476
|
+
notes: uniqueSorted2(mergedProviders.flatMap((provider) => provider.notes))
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
function detectProvider(name, dependencyMatched, evidenceFiles, note) {
|
|
480
|
+
const hasEvidenceFiles = evidenceFiles.length > 0;
|
|
481
|
+
if (!dependencyMatched && !hasEvidenceFiles) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
let confidence = "low";
|
|
485
|
+
if (dependencyMatched && hasEvidenceFiles) {
|
|
486
|
+
confidence = "high";
|
|
487
|
+
} else if (dependencyMatched || hasEvidenceFiles) {
|
|
488
|
+
confidence = "medium";
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
name,
|
|
492
|
+
confidence,
|
|
493
|
+
evidenceFiles: evidenceFiles.slice(0, 12),
|
|
494
|
+
notes: [note]
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
function pushProvider(target, signal) {
|
|
498
|
+
if (signal) {
|
|
499
|
+
target.push(signal);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
async function indexCode(root, files) {
|
|
503
|
+
const indexed = [];
|
|
504
|
+
for (const file of files) {
|
|
505
|
+
const text = await readTextIfExists(join3(root, file));
|
|
506
|
+
if (!text) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
indexed.push({ file, text });
|
|
510
|
+
}
|
|
511
|
+
return indexed;
|
|
512
|
+
}
|
|
513
|
+
function findFiles(index, pattern) {
|
|
514
|
+
return uniqueSorted2(index.filter((entry) => pattern.test(entry.text) || pattern.test(entry.file)).map((entry) => entry.file));
|
|
515
|
+
}
|
|
516
|
+
function uniqueSignals(signals) {
|
|
517
|
+
const map = /* @__PURE__ */ new Map();
|
|
518
|
+
for (const signal of signals) {
|
|
519
|
+
const existing = map.get(signal.name);
|
|
520
|
+
if (!existing) {
|
|
521
|
+
map.set(signal.name, signal);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
map.set(signal.name, {
|
|
525
|
+
...signal,
|
|
526
|
+
confidence: maxConfidence(existing.confidence, signal.confidence),
|
|
527
|
+
evidenceFiles: uniqueSorted2([...existing.evidenceFiles, ...signal.evidenceFiles]).slice(0, 12),
|
|
528
|
+
notes: uniqueSorted2([...existing.notes, ...signal.notes])
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
532
|
+
}
|
|
533
|
+
function maxConfidence(left, right) {
|
|
534
|
+
const rank = { low: 1, medium: 2, high: 3 };
|
|
535
|
+
return rank[left] >= rank[right] ? left : right;
|
|
536
|
+
}
|
|
537
|
+
function uniqueSorted2(values) {
|
|
538
|
+
return [...new Set(values)].sort();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// src/detectors/database.ts
|
|
542
|
+
import fg4 from "fast-glob";
|
|
543
|
+
import { join as join4 } from "path";
|
|
544
|
+
async function detectDatabase(root, outDir) {
|
|
545
|
+
const ignore = defaultIgnores(outDir);
|
|
546
|
+
const [
|
|
547
|
+
prismaFiles,
|
|
548
|
+
drizzleFiles,
|
|
549
|
+
typeOrmFiles,
|
|
550
|
+
mongoFiles,
|
|
551
|
+
firebaseFiles,
|
|
552
|
+
supabaseFiles,
|
|
553
|
+
sqlFiles,
|
|
554
|
+
migrationFiles,
|
|
555
|
+
dbClientFiles,
|
|
556
|
+
pkg
|
|
557
|
+
] = await Promise.all([
|
|
558
|
+
fg4(["prisma/**/*.prisma"], { cwd: root, ignore }),
|
|
559
|
+
fg4(["drizzle/**/*", "drizzle.config.@(ts|js|mjs|cjs)", "db/drizzle/**/*"], {
|
|
560
|
+
cwd: root,
|
|
561
|
+
ignore
|
|
562
|
+
}),
|
|
563
|
+
fg4(["ormconfig.@(ts|js|json)", "src/entity/**/*", "**/*.entity.@(ts|js)"], {
|
|
564
|
+
cwd: root,
|
|
565
|
+
ignore
|
|
566
|
+
}),
|
|
567
|
+
fg4(["**/*mongo*.@(ts|tsx|js|jsx)", "**/*mongoose*.@(ts|tsx|js|jsx)"], {
|
|
568
|
+
cwd: root,
|
|
569
|
+
ignore
|
|
570
|
+
}),
|
|
571
|
+
fg4(["**/*firebase*.@(ts|tsx|js|jsx)", "**/*firestore*.@(ts|tsx|js|jsx)"], {
|
|
572
|
+
cwd: root,
|
|
573
|
+
ignore
|
|
574
|
+
}),
|
|
575
|
+
fg4(["supabase/**/*", "**/*supabase*.@(ts|tsx|js|jsx)"], { cwd: root, ignore }),
|
|
576
|
+
fg4(["**/*.sql"], { cwd: root, ignore }),
|
|
577
|
+
fg4(["**/migrations/**/*", "**/migration/**/*", "**/migrate/**/*"], { cwd: root, ignore }),
|
|
578
|
+
fg4(["**/*db*.@(ts|tsx|js|jsx)", "**/*database*.@(ts|tsx|js|jsx)", "**/lib/*sql*.@(ts|tsx|js|jsx)"], {
|
|
579
|
+
cwd: root,
|
|
580
|
+
ignore
|
|
581
|
+
}),
|
|
582
|
+
readJsonIfExists(join4(root, "package.json"))
|
|
583
|
+
]);
|
|
584
|
+
const deps = /* @__PURE__ */ new Set([
|
|
585
|
+
...Object.keys(pkg?.dependencies ?? {}),
|
|
586
|
+
...Object.keys(pkg?.devDependencies ?? {})
|
|
587
|
+
]);
|
|
588
|
+
const providers = [];
|
|
589
|
+
addSignal(
|
|
590
|
+
providers,
|
|
591
|
+
"prisma",
|
|
592
|
+
deps.has("prisma") || deps.has("@prisma/client"),
|
|
593
|
+
prismaFiles,
|
|
594
|
+
"Prisma dependency and schema files"
|
|
595
|
+
);
|
|
596
|
+
addSignal(
|
|
597
|
+
providers,
|
|
598
|
+
"drizzle",
|
|
599
|
+
deps.has("drizzle-orm") || deps.has("drizzle-kit"),
|
|
600
|
+
drizzleFiles,
|
|
601
|
+
"Drizzle dependency and config/files"
|
|
602
|
+
);
|
|
603
|
+
addSignal(
|
|
604
|
+
providers,
|
|
605
|
+
"supabase",
|
|
606
|
+
deps.has("@supabase/supabase-js"),
|
|
607
|
+
supabaseFiles,
|
|
608
|
+
"Supabase SDK and related files"
|
|
609
|
+
);
|
|
610
|
+
addSignal(
|
|
611
|
+
providers,
|
|
612
|
+
"typeorm",
|
|
613
|
+
deps.has("typeorm"),
|
|
614
|
+
typeOrmFiles,
|
|
615
|
+
"TypeORM dependency and entity/config files"
|
|
616
|
+
);
|
|
617
|
+
addSignal(
|
|
618
|
+
providers,
|
|
619
|
+
"mongoose-mongodb",
|
|
620
|
+
deps.has("mongoose") || deps.has("mongodb"),
|
|
621
|
+
mongoFiles,
|
|
622
|
+
"MongoDB/Mongoose dependency and files"
|
|
623
|
+
);
|
|
624
|
+
addSignal(
|
|
625
|
+
providers,
|
|
626
|
+
"firebase-firestore",
|
|
627
|
+
deps.has("firebase") || deps.has("firebase-admin") || deps.has("@google-cloud/firestore"),
|
|
628
|
+
firebaseFiles,
|
|
629
|
+
"Firebase/Firestore dependency and files"
|
|
630
|
+
);
|
|
631
|
+
addSignal(
|
|
632
|
+
providers,
|
|
633
|
+
"postgres-client",
|
|
634
|
+
deps.has("pg") || deps.has("postgres") || deps.has("slonik"),
|
|
635
|
+
dbClientFiles.filter((f) => /postgres|pg/i.test(f)),
|
|
636
|
+
"PostgreSQL client dependencies/files"
|
|
637
|
+
);
|
|
638
|
+
addSignal(
|
|
639
|
+
providers,
|
|
640
|
+
"mysql-client",
|
|
641
|
+
deps.has("mysql") || deps.has("mysql2"),
|
|
642
|
+
dbClientFiles.filter((f) => /mysql/i.test(f)),
|
|
643
|
+
"MySQL client dependencies/files"
|
|
644
|
+
);
|
|
645
|
+
addSignal(
|
|
646
|
+
providers,
|
|
647
|
+
"sqlite",
|
|
648
|
+
deps.has("sqlite3") || deps.has("better-sqlite3"),
|
|
649
|
+
dbClientFiles.filter((f) => /sqlite/i.test(f)),
|
|
650
|
+
"SQLite dependencies/files"
|
|
651
|
+
);
|
|
652
|
+
if (sqlFiles.length > 0 || migrationFiles.length > 0) {
|
|
653
|
+
providers.push({
|
|
654
|
+
name: "sql-migrations",
|
|
655
|
+
confidence: sqlFiles.length > 0 && migrationFiles.length > 0 ? "high" : "medium",
|
|
656
|
+
evidenceFiles: uniqueSorted3([...sqlFiles, ...migrationFiles]).slice(0, 12),
|
|
657
|
+
notes: ["SQL files and migration-style paths detected"]
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
const hasKnownProvider = providers.some((provider) => provider.name !== "sql-migrations");
|
|
661
|
+
const customLayerFiles = dbClientFiles.filter(
|
|
662
|
+
(file) => !/supabase|prisma|drizzle|typeorm|mongo|mongoose|firebase|firestore/i.test(file)
|
|
663
|
+
);
|
|
664
|
+
if (!hasKnownProvider && customLayerFiles.length > 0) {
|
|
665
|
+
providers.push({
|
|
666
|
+
name: "custom-database-layer",
|
|
667
|
+
confidence: "low",
|
|
668
|
+
evidenceFiles: uniqueSorted3(customLayerFiles).slice(0, 12),
|
|
669
|
+
notes: ["Database-like files found without strong provider signals"]
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
if (providers.length === 0) {
|
|
673
|
+
providers.push({
|
|
674
|
+
name: "unknown",
|
|
675
|
+
confidence: "low",
|
|
676
|
+
evidenceFiles: [],
|
|
677
|
+
notes: ["No clear database provider detected"]
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
const schemaFiles = uniqueSorted3([
|
|
681
|
+
...prismaFiles,
|
|
682
|
+
...sqlFiles.filter((file) => /schema|setup|init/i.test(file))
|
|
683
|
+
]);
|
|
684
|
+
return {
|
|
685
|
+
providers: uniqueSignals2(providers),
|
|
686
|
+
files: uniqueSorted3([
|
|
687
|
+
...prismaFiles,
|
|
688
|
+
...drizzleFiles,
|
|
689
|
+
...typeOrmFiles,
|
|
690
|
+
...mongoFiles,
|
|
691
|
+
...firebaseFiles,
|
|
692
|
+
...supabaseFiles,
|
|
693
|
+
...sqlFiles,
|
|
694
|
+
...migrationFiles,
|
|
695
|
+
...dbClientFiles
|
|
696
|
+
]),
|
|
697
|
+
schemaFiles,
|
|
698
|
+
migrations: uniqueSorted3(migrationFiles),
|
|
699
|
+
clientFiles: uniqueSorted3(dbClientFiles),
|
|
700
|
+
notes: buildDatabaseNotes(uniqueSignals2(providers))
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
function addSignal(target, name, hasDependency, evidenceFiles, note) {
|
|
704
|
+
const hasFiles = evidenceFiles.length > 0;
|
|
705
|
+
if (!hasDependency && !hasFiles) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
let confidence = "low";
|
|
709
|
+
if (hasDependency && hasFiles) {
|
|
710
|
+
confidence = "high";
|
|
711
|
+
} else if (hasDependency || hasFiles) {
|
|
712
|
+
confidence = "medium";
|
|
713
|
+
}
|
|
714
|
+
target.push({
|
|
715
|
+
name,
|
|
716
|
+
confidence,
|
|
717
|
+
evidenceFiles: uniqueSorted3(evidenceFiles).slice(0, 12),
|
|
718
|
+
notes: [note]
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function uniqueSignals2(signals) {
|
|
722
|
+
const map = /* @__PURE__ */ new Map();
|
|
723
|
+
for (const signal of signals) {
|
|
724
|
+
const existing = map.get(signal.name);
|
|
725
|
+
if (!existing) {
|
|
726
|
+
map.set(signal.name, signal);
|
|
727
|
+
continue;
|
|
728
|
+
}
|
|
729
|
+
map.set(signal.name, {
|
|
730
|
+
...signal,
|
|
731
|
+
confidence: maxConfidence2(existing.confidence, signal.confidence),
|
|
732
|
+
evidenceFiles: uniqueSorted3([...existing.evidenceFiles, ...signal.evidenceFiles]).slice(0, 12),
|
|
733
|
+
notes: uniqueSorted3([...existing.notes, ...signal.notes])
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
737
|
+
}
|
|
738
|
+
function maxConfidence2(left, right) {
|
|
739
|
+
const rank = { low: 1, medium: 2, high: 3 };
|
|
740
|
+
return rank[left] >= rank[right] ? left : right;
|
|
741
|
+
}
|
|
742
|
+
function buildDatabaseNotes(providers) {
|
|
743
|
+
if (providers.length === 0) {
|
|
744
|
+
return ["unknown"];
|
|
745
|
+
}
|
|
746
|
+
return providers.map((provider) => `${provider.name}: ${provider.confidence} confidence`);
|
|
747
|
+
}
|
|
748
|
+
function uniqueSorted3(values) {
|
|
749
|
+
return [...new Set(values)].sort();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/detectors/routes.ts
|
|
753
|
+
import fg5 from "fast-glob";
|
|
754
|
+
import { posix } from "path";
|
|
755
|
+
var APP_PAGE_GLOBS = [
|
|
756
|
+
"app/**/page.@(ts|tsx|js|jsx|mdx)",
|
|
757
|
+
"src/app/**/page.@(ts|tsx|js|jsx|mdx)"
|
|
758
|
+
];
|
|
759
|
+
var APP_API_GLOBS = [
|
|
760
|
+
"app/**/route.@(ts|tsx|js|jsx)",
|
|
761
|
+
"src/app/**/route.@(ts|tsx|js|jsx)"
|
|
762
|
+
];
|
|
763
|
+
var PAGES_API_GLOBS = [
|
|
764
|
+
"pages/api/**/*.@(ts|tsx|js|jsx)",
|
|
765
|
+
"src/pages/api/**/*.@(ts|tsx|js|jsx)"
|
|
766
|
+
];
|
|
767
|
+
async function detectRoutes(root, outDir) {
|
|
768
|
+
const ignore = defaultIgnores(outDir);
|
|
769
|
+
const [appPages, appApiRoutes, pagesApiRoutes] = await Promise.all([
|
|
770
|
+
fg5(APP_PAGE_GLOBS, { cwd: root, ignore, dot: false }),
|
|
771
|
+
fg5(APP_API_GLOBS, { cwd: root, ignore, dot: false }),
|
|
772
|
+
fg5(PAGES_API_GLOBS, { cwd: root, ignore, dot: false })
|
|
773
|
+
]);
|
|
774
|
+
const pageItems = appPages.map((file) => ({
|
|
775
|
+
kind: "page",
|
|
776
|
+
route: appPageToRoute(file),
|
|
777
|
+
file,
|
|
778
|
+
source: "app"
|
|
779
|
+
}));
|
|
780
|
+
const appApiItems = appApiRoutes.map((file) => ({
|
|
781
|
+
kind: "api",
|
|
782
|
+
route: appApiToRoute(file),
|
|
783
|
+
file,
|
|
784
|
+
source: "app"
|
|
785
|
+
}));
|
|
786
|
+
const pagesApiItems = pagesApiRoutes.map((file) => ({
|
|
787
|
+
kind: "api",
|
|
788
|
+
route: pagesApiToRoute(file),
|
|
789
|
+
file,
|
|
790
|
+
source: "pages"
|
|
791
|
+
}));
|
|
792
|
+
return [...pageItems, ...appApiItems, ...pagesApiItems].sort(
|
|
793
|
+
(a, b) => a.route.localeCompare(b.route)
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
function appPageToRoute(file) {
|
|
797
|
+
const normalized = file.replace(/\\/g, "/");
|
|
798
|
+
const rootRelative = stripPrefix(normalized, ["src/app/", "app/"]);
|
|
799
|
+
const segments = rootRelative.split("/").slice(0, -1);
|
|
800
|
+
const cleanSegments = segments.filter((segment) => !segment.startsWith("(") && !segment.endsWith(")")).filter((segment) => !segment.startsWith("@"));
|
|
801
|
+
if (cleanSegments.length === 0) {
|
|
802
|
+
return "/";
|
|
803
|
+
}
|
|
804
|
+
return `/${cleanSegments.join("/")}`;
|
|
805
|
+
}
|
|
806
|
+
function appApiToRoute(file) {
|
|
807
|
+
const normalized = file.replace(/\\/g, "/");
|
|
808
|
+
const noPrefix = stripPrefix(normalized, ["src/app/", "app/"]);
|
|
809
|
+
const withoutFile = noPrefix.replace(/\/route\.[^.]+$/, "");
|
|
810
|
+
return ensureLeadingSlash(withoutFile);
|
|
811
|
+
}
|
|
812
|
+
function pagesApiToRoute(file) {
|
|
813
|
+
const normalized = file.replace(/\\/g, "/");
|
|
814
|
+
const noPrefix = stripPrefix(normalized, ["src/pages/", "pages/"]);
|
|
815
|
+
const withoutExt = noPrefix.replace(/\.[^.]+$/, "");
|
|
816
|
+
const withoutIndex = withoutExt.endsWith("/index") ? withoutExt.slice(0, -"/index".length) : withoutExt;
|
|
817
|
+
return ensureLeadingSlash(posix.join(withoutIndex));
|
|
818
|
+
}
|
|
819
|
+
function ensureLeadingSlash(path) {
|
|
820
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
821
|
+
}
|
|
822
|
+
function stripPrefix(path, prefixes) {
|
|
823
|
+
for (const prefix of prefixes) {
|
|
824
|
+
if (path.startsWith(prefix)) {
|
|
825
|
+
return path.slice(prefix.length);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return path;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/detectors/server-actions.ts
|
|
832
|
+
import fg6 from "fast-glob";
|
|
833
|
+
import { join as join5 } from "path";
|
|
834
|
+
var CODE_FILES = "**/*.@(ts|tsx|js|jsx)";
|
|
835
|
+
async function detectServerActions(root, outDir) {
|
|
836
|
+
const ignore = defaultIgnores(outDir);
|
|
837
|
+
const files = await fg6([CODE_FILES], { cwd: root, ignore });
|
|
838
|
+
const matched = [];
|
|
839
|
+
for (const file of files) {
|
|
840
|
+
const fullPath = join5(root, file);
|
|
841
|
+
const text = await readTextIfExists(fullPath);
|
|
842
|
+
if (!text) {
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
if (hasUseServerDirective(text)) {
|
|
846
|
+
matched.push(file);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const uniqueFiles = [...new Set(matched)].sort();
|
|
850
|
+
return {
|
|
851
|
+
count: uniqueFiles.length,
|
|
852
|
+
files: uniqueFiles
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
function hasUseServerDirective(text) {
|
|
856
|
+
return text.includes('"use server"') || text.includes("'use server'") || text.includes("`use server`");
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/writers/markdown.ts
|
|
860
|
+
import { join as join6 } from "path";
|
|
861
|
+
async function writeMarkdownReports(report, outDirAbsolute) {
|
|
862
|
+
const files = {
|
|
863
|
+
FORGE_CONTEXT: join6(outDirAbsolute, "FORGE_CONTEXT.md"),
|
|
864
|
+
ARCHITECTURE_MAP: join6(outDirAbsolute, "ARCHITECTURE_MAP.md"),
|
|
865
|
+
ROUTES_MAP: join6(outDirAbsolute, "ROUTES_MAP.md"),
|
|
866
|
+
DATABASE_MAP: join6(outDirAbsolute, "DATABASE_MAP.md"),
|
|
867
|
+
SERVER_ACTIONS_MAP: join6(outDirAbsolute, "SERVER_ACTIONS_MAP.md"),
|
|
868
|
+
SECURITY_RULES: join6(outDirAbsolute, "SECURITY_RULES.md"),
|
|
869
|
+
RISK_REPORT: join6(outDirAbsolute, "RISK_REPORT.md")
|
|
870
|
+
};
|
|
871
|
+
await Promise.all([
|
|
872
|
+
writeText(files.FORGE_CONTEXT, renderForgeContext(report)),
|
|
873
|
+
writeText(files.ARCHITECTURE_MAP, renderArchitectureMap(report)),
|
|
874
|
+
writeText(files.ROUTES_MAP, renderRoutesMap(report)),
|
|
875
|
+
writeText(files.DATABASE_MAP, renderDatabaseMap(report)),
|
|
876
|
+
writeText(files.SERVER_ACTIONS_MAP, renderServerActionsMap(report)),
|
|
877
|
+
writeText(files.SECURITY_RULES, renderSecurityRules(report)),
|
|
878
|
+
writeText(files.RISK_REPORT, renderRiskReport(report))
|
|
879
|
+
]);
|
|
880
|
+
return files;
|
|
881
|
+
}
|
|
882
|
+
function renderForgeContext(report) {
|
|
883
|
+
return [
|
|
884
|
+
"# FORGE_CONTEXT",
|
|
885
|
+
"",
|
|
886
|
+
`- Root: \`${report.root}\``,
|
|
887
|
+
`- Scanned at: ${report.scannedAt}`,
|
|
888
|
+
`- Framework: ${report.project.framework}`,
|
|
889
|
+
`- Language: ${report.project.language}`,
|
|
890
|
+
`- Package manager: ${report.project.packageManager}`,
|
|
891
|
+
`- Route count: ${report.routes.length}`,
|
|
892
|
+
`- API route count: ${report.routes.filter((r) => r.kind === "api").length}`,
|
|
893
|
+
`- Server actions: ${report.serverActions.count}`,
|
|
894
|
+
`- Database providers: ${signalsSummary(report.database.providers)}`,
|
|
895
|
+
`- Auth providers: ${signalsSummary(report.auth.providers)}`,
|
|
896
|
+
"",
|
|
897
|
+
"## Package Scripts",
|
|
898
|
+
...renderKeyValueMap(report.project.scripts)
|
|
899
|
+
].join("\n");
|
|
900
|
+
}
|
|
901
|
+
function renderArchitectureMap(report) {
|
|
902
|
+
return [
|
|
903
|
+
"# ARCHITECTURE_MAP",
|
|
904
|
+
"",
|
|
905
|
+
"## Project",
|
|
906
|
+
`- Framework: ${report.project.framework}`,
|
|
907
|
+
`- Language: ${report.project.language}`,
|
|
908
|
+
"",
|
|
909
|
+
"## Important Areas",
|
|
910
|
+
`- App/API routes: ${report.routes.length > 0 ? "detected" : "unknown"}`,
|
|
911
|
+
`- Database: ${report.database.files.length > 0 ? "detected" : "unknown"}`,
|
|
912
|
+
`- Auth: ${hasNonUnknown(report.auth.providers) ? "detected" : "unknown"}`,
|
|
913
|
+
`- Middleware: ${report.security.middlewareFiles.length > 0 ? "detected" : "unknown"}`,
|
|
914
|
+
"",
|
|
915
|
+
"## Key Files",
|
|
916
|
+
...renderListWithFallback(
|
|
917
|
+
uniqueSorted4([
|
|
918
|
+
...report.auth.files,
|
|
919
|
+
...report.security.middlewareFiles,
|
|
920
|
+
...report.database.schemaFiles,
|
|
921
|
+
...report.serverActions.files
|
|
922
|
+
]),
|
|
923
|
+
"unknown"
|
|
924
|
+
)
|
|
925
|
+
].join("\n");
|
|
926
|
+
}
|
|
927
|
+
function renderRoutesMap(report) {
|
|
928
|
+
const lines = [
|
|
929
|
+
"# ROUTES_MAP",
|
|
930
|
+
"",
|
|
931
|
+
"| Kind | Route | Source | File |",
|
|
932
|
+
"|---|---|---|---|"
|
|
933
|
+
];
|
|
934
|
+
if (report.routes.length === 0) {
|
|
935
|
+
lines.push("| unknown | unknown | unknown | unknown |");
|
|
936
|
+
return lines.join("\n");
|
|
937
|
+
}
|
|
938
|
+
for (const route of report.routes) {
|
|
939
|
+
lines.push(`| ${route.kind} | \`${route.route}\` | ${route.source} | \`${route.file}\` |`);
|
|
940
|
+
}
|
|
941
|
+
return lines.join("\n");
|
|
942
|
+
}
|
|
943
|
+
function renderDatabaseMap(report) {
|
|
944
|
+
return [
|
|
945
|
+
"# DATABASE_MAP",
|
|
946
|
+
"",
|
|
947
|
+
"## Detected Providers",
|
|
948
|
+
...renderSignals(report.database.providers),
|
|
949
|
+
"",
|
|
950
|
+
"## Schema Files",
|
|
951
|
+
...renderListWithFallback(report.database.schemaFiles, "unknown"),
|
|
952
|
+
"",
|
|
953
|
+
"## Migration Files",
|
|
954
|
+
...renderListWithFallback(report.database.migrations, "unknown"),
|
|
955
|
+
"",
|
|
956
|
+
"## Database Clients",
|
|
957
|
+
...renderListWithFallback(report.database.clientFiles, "unknown"),
|
|
958
|
+
"",
|
|
959
|
+
"## Unknown/Manual Review Notes",
|
|
960
|
+
...renderListWithFallback(report.database.notes, "unknown")
|
|
961
|
+
].join("\n");
|
|
962
|
+
}
|
|
963
|
+
function renderServerActionsMap(report) {
|
|
964
|
+
return [
|
|
965
|
+
"# SERVER_ACTIONS_MAP",
|
|
966
|
+
"",
|
|
967
|
+
`- Total files: ${report.serverActions.count}`,
|
|
968
|
+
"",
|
|
969
|
+
"## Files",
|
|
970
|
+
...renderListWithFallback(report.serverActions.files, "unknown")
|
|
971
|
+
].join("\n");
|
|
972
|
+
}
|
|
973
|
+
function renderSecurityRules(report) {
|
|
974
|
+
const apiRouteFiles = report.routes.filter((route) => route.kind === "api").map((route) => route.file);
|
|
975
|
+
return [
|
|
976
|
+
"# SECURITY_RULES",
|
|
977
|
+
"",
|
|
978
|
+
"## Auth providers/signals detected",
|
|
979
|
+
...renderSignals(report.auth.providers),
|
|
980
|
+
"",
|
|
981
|
+
"## Auth evidence files",
|
|
982
|
+
...renderListWithFallback(report.auth.evidenceFiles, "unknown"),
|
|
983
|
+
"",
|
|
984
|
+
"## Middleware status",
|
|
985
|
+
...renderListWithFallback(report.security.middlewareFiles, "unknown"),
|
|
986
|
+
"",
|
|
987
|
+
"## Server actions requiring review",
|
|
988
|
+
...renderListWithFallback(report.serverActions.files, "unknown"),
|
|
989
|
+
"",
|
|
990
|
+
"## API routes requiring review",
|
|
991
|
+
...renderListWithFallback(apiRouteFiles, "unknown"),
|
|
992
|
+
"",
|
|
993
|
+
"## Environment files (names only)",
|
|
994
|
+
...renderListWithFallback(report.security.envFiles, "unknown"),
|
|
995
|
+
"",
|
|
996
|
+
"## Admin/security-sensitive areas",
|
|
997
|
+
...renderListWithFallback(report.security.sensitiveFiles, "unknown"),
|
|
998
|
+
"",
|
|
999
|
+
"## Manual verification checklist",
|
|
1000
|
+
...buildSecurityChecklist(report).map((item) => `- [ ] ${item}`)
|
|
1001
|
+
].join("\n");
|
|
1002
|
+
}
|
|
1003
|
+
function renderRiskReport(report) {
|
|
1004
|
+
const risks = [];
|
|
1005
|
+
const apiRoutes = report.routes.filter((route) => route.kind === "api");
|
|
1006
|
+
const adminRoutes = report.routes.filter(
|
|
1007
|
+
(route) => route.route.includes("/admin") || route.route === "/admin"
|
|
1008
|
+
);
|
|
1009
|
+
const authNames = report.auth.providers.map((provider) => provider.name);
|
|
1010
|
+
if (adminRoutes.length > 0 && report.security.middlewareFiles.length === 0) {
|
|
1011
|
+
risks.push(
|
|
1012
|
+
`Admin routes detected but no middleware: ${adminRoutes.map((route) => `\`${route.file}\``).join(", ")}. Verify authorization guards.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
if (report.serverActions.count > 0) {
|
|
1016
|
+
risks.push(
|
|
1017
|
+
`Server actions detected (${report.serverActions.count}): ${report.serverActions.files.slice(0, 6).map((file) => `\`${file}\``).join(", ")}. Verify auth and input validation.`
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
if (apiRoutes.length > 0) {
|
|
1021
|
+
risks.push(
|
|
1022
|
+
`API routes detected (${apiRoutes.length}): ${apiRoutes.slice(0, 6).map((route) => `\`${route.file}\``).join(", ")}. Verify auth and input validation.`
|
|
1023
|
+
);
|
|
1024
|
+
} else {
|
|
1025
|
+
risks.push("No API routes detected.");
|
|
1026
|
+
}
|
|
1027
|
+
const dbProviders = report.database.providers.filter((provider) => provider.name !== "unknown");
|
|
1028
|
+
if (dbProviders.length > 0) {
|
|
1029
|
+
risks.push(
|
|
1030
|
+
`Database providers detected: ${dbProviders.map((provider) => `${provider.name} (${provider.confidence})`).join(", ")}. Verify credentials are server-only.`
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (report.security.envFiles.length > 0) {
|
|
1034
|
+
risks.push(
|
|
1035
|
+
`Environment files detected (names only): ${report.security.envFiles.map((file) => `\`${file}\``).join(", ")}.`
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
const weakAuthSignals = authNames.filter((name) => name === "unknown" || name === "custom-auth");
|
|
1039
|
+
if (weakAuthSignals.length > 0) {
|
|
1040
|
+
risks.push("Auth provider is custom/unknown. Manual review required.");
|
|
1041
|
+
}
|
|
1042
|
+
if (report.security.riskyFiles.length > 0) {
|
|
1043
|
+
risks.push(
|
|
1044
|
+
`Potential risky patterns found in: ${report.security.riskyFiles.map((file) => `\`${file}\``).join(", ")}.`
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
if (risks.length === 0) {
|
|
1048
|
+
risks.push("No obvious static risks found. Dynamic/runtime risks remain unknown.");
|
|
1049
|
+
}
|
|
1050
|
+
return [
|
|
1051
|
+
"# RISK_REPORT",
|
|
1052
|
+
"",
|
|
1053
|
+
"## Summary",
|
|
1054
|
+
...risks.map((risk) => `- ${risk}`),
|
|
1055
|
+
"",
|
|
1056
|
+
"## Unknowns",
|
|
1057
|
+
"- Authorization guard coverage inside route handlers is unknown.",
|
|
1058
|
+
"- Row-level tenant/account isolation checks are unknown.",
|
|
1059
|
+
"- Runtime secrets handling and deployment config are unknown."
|
|
1060
|
+
].join("\n");
|
|
1061
|
+
}
|
|
1062
|
+
function renderSignals(signals) {
|
|
1063
|
+
if (signals.length === 0) {
|
|
1064
|
+
return ["- unknown"];
|
|
1065
|
+
}
|
|
1066
|
+
return signals.flatMap((signal) => {
|
|
1067
|
+
const lines = [`- ${signal.name} (confidence: ${signal.confidence})`];
|
|
1068
|
+
lines.push(` evidence: ${renderInlineEvidence(signal.evidenceFiles)}`);
|
|
1069
|
+
if (signal.notes.length > 0) {
|
|
1070
|
+
lines.push(` notes: ${signal.notes.join("; ")}`);
|
|
1071
|
+
}
|
|
1072
|
+
return lines;
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
function renderInlineEvidence(files) {
|
|
1076
|
+
if (files.length === 0) {
|
|
1077
|
+
return "unknown";
|
|
1078
|
+
}
|
|
1079
|
+
return files.map((file) => `\`${file}\``).join(", ");
|
|
1080
|
+
}
|
|
1081
|
+
function renderKeyValueMap(values) {
|
|
1082
|
+
const entries = Object.entries(values);
|
|
1083
|
+
if (entries.length === 0) {
|
|
1084
|
+
return ["- unknown"];
|
|
1085
|
+
}
|
|
1086
|
+
return entries.sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `- \`${key}\`: \`${value}\``);
|
|
1087
|
+
}
|
|
1088
|
+
function renderListWithFallback(items, fallback) {
|
|
1089
|
+
if (items.length === 0) {
|
|
1090
|
+
return [`- ${fallback}`];
|
|
1091
|
+
}
|
|
1092
|
+
return items.map((item) => `- \`${item}\``);
|
|
1093
|
+
}
|
|
1094
|
+
function signalsSummary(signals) {
|
|
1095
|
+
if (signals.length === 0) {
|
|
1096
|
+
return "unknown";
|
|
1097
|
+
}
|
|
1098
|
+
return signals.map((signal) => `${signal.name} (${signal.confidence})`).join(", ");
|
|
1099
|
+
}
|
|
1100
|
+
function hasNonUnknown(signals) {
|
|
1101
|
+
return signals.some((signal) => signal.name !== "unknown");
|
|
1102
|
+
}
|
|
1103
|
+
function uniqueSorted4(values) {
|
|
1104
|
+
return [...new Set(values)].sort();
|
|
1105
|
+
}
|
|
1106
|
+
function buildSecurityChecklist(report) {
|
|
1107
|
+
const checklist = [
|
|
1108
|
+
"Confirm auth checks for admin pages and server actions.",
|
|
1109
|
+
"Confirm API routes validate input and enforce auth.",
|
|
1110
|
+
"Confirm server actions validate input and enforce auth.",
|
|
1111
|
+
"Confirm secrets are never exposed to client code."
|
|
1112
|
+
];
|
|
1113
|
+
if (report.security.middlewareFiles.length === 0) {
|
|
1114
|
+
checklist.push("Review if middleware is required for route protection.");
|
|
1115
|
+
}
|
|
1116
|
+
return checklist;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// src/scan.ts
|
|
1120
|
+
async function scanRepo(root, outDir) {
|
|
1121
|
+
const absoluteRoot = resolve3(root);
|
|
1122
|
+
const [project, routes, database, auth, serverActions, security] = await Promise.all([
|
|
1123
|
+
detectProject(absoluteRoot),
|
|
1124
|
+
detectRoutes(absoluteRoot, outDir),
|
|
1125
|
+
detectDatabase(absoluteRoot, outDir),
|
|
1126
|
+
detectAuth(absoluteRoot, outDir),
|
|
1127
|
+
detectServerActions(absoluteRoot, outDir),
|
|
1128
|
+
detectSecurity(absoluteRoot, outDir)
|
|
1129
|
+
]);
|
|
1130
|
+
return {
|
|
1131
|
+
root: absoluteRoot,
|
|
1132
|
+
scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1133
|
+
project,
|
|
1134
|
+
routes,
|
|
1135
|
+
database,
|
|
1136
|
+
auth,
|
|
1137
|
+
serverActions,
|
|
1138
|
+
security
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
async function runScan(options) {
|
|
1142
|
+
const rootAbsolute = resolve3(options.root);
|
|
1143
|
+
const outDirAbsolute = resolve3(rootAbsolute, options.outDir);
|
|
1144
|
+
if (options.format !== "markdown") {
|
|
1145
|
+
throw new Error(`Unsupported format: ${options.format}`);
|
|
1146
|
+
}
|
|
1147
|
+
if (!isPathInsideRoot(rootAbsolute, outDirAbsolute)) {
|
|
1148
|
+
throw new Error("Output folder must be inside the selected root folder.");
|
|
1149
|
+
}
|
|
1150
|
+
const report = await scanRepo(rootAbsolute, options.outDir);
|
|
1151
|
+
const files = await writeMarkdownReports(report, outDirAbsolute);
|
|
1152
|
+
return {
|
|
1153
|
+
report,
|
|
1154
|
+
files,
|
|
1155
|
+
outDirAbsolute
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function isPathInsideRoot(root, target) {
|
|
1159
|
+
const pathFromRoot = relative(root, target);
|
|
1160
|
+
return pathFromRoot !== "" && !pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/cli.ts
|
|
1164
|
+
var program = new Command();
|
|
1165
|
+
program.name("forgelens").description("Local-first CLI for repo context scanning for AI coding agents").version("0.1.0");
|
|
1166
|
+
program.command("scan").description("Scan a repository and generate context markdown files").option("--out <path>", "output folder (inside root)", ".forgelens").option("--root <path>", "repository root path", ".").option("--format <format>", "output format (markdown)", "markdown").option("--verbose", "print scan details", false).addHelpText(
|
|
1167
|
+
"after",
|
|
1168
|
+
"\nExamples:\n forgelens scan\n forgelens scan --root . --out .forgelens --verbose"
|
|
1169
|
+
).action(async (cmdOptions) => {
|
|
1170
|
+
const options = {
|
|
1171
|
+
outDir: cmdOptions.out,
|
|
1172
|
+
root: cmdOptions.root,
|
|
1173
|
+
format: cmdOptions.format,
|
|
1174
|
+
verbose: Boolean(cmdOptions.verbose)
|
|
1175
|
+
};
|
|
1176
|
+
try {
|
|
1177
|
+
const result = await runScan(options);
|
|
1178
|
+
if (options.verbose) {
|
|
1179
|
+
console.log(`Root: ${result.report.root}`);
|
|
1180
|
+
console.log(`Routes found: ${result.report.routes.length}`);
|
|
1181
|
+
console.log(`Server actions found: ${result.report.serverActions.count}`);
|
|
1182
|
+
}
|
|
1183
|
+
console.log(`ForgeLens scan complete: ${result.outDirAbsolute}`);
|
|
1184
|
+
for (const [name, filePath] of Object.entries(result.files)) {
|
|
1185
|
+
console.log(`- ${name}: ${filePath}`);
|
|
1186
|
+
}
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1189
|
+
console.error(`ForgeLens scan failed: ${message}`);
|
|
1190
|
+
process.exitCode = 1;
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
program.command("doctor").description("Check scan safety/readiness without writing any files").option("--root <path>", "repository root path", ".").option("--out <path>", "output folder that scan would use", ".forgelens").addHelpText("after", "\nExample:\n forgelens doctor --root . --out .forgelens").action(async (cmdOptions) => {
|
|
1194
|
+
try {
|
|
1195
|
+
const report = await inspectRepoSafety({
|
|
1196
|
+
root: cmdOptions.root,
|
|
1197
|
+
outDir: cmdOptions.out
|
|
1198
|
+
});
|
|
1199
|
+
console.log(renderDoctorReport(report));
|
|
1200
|
+
} catch (error) {
|
|
1201
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1202
|
+
console.error(`ForgeLens doctor failed: ${message}`);
|
|
1203
|
+
process.exitCode = 1;
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
program.command("clean").description("Remove only generated output folder").option("--root <path>", "repository root path", ".").option("--out <path>", "output folder to remove", ".forgelens").option("--yes", "skip confirmation prompt", false).addHelpText("after", "\nExample:\n forgelens clean --out .forgelens --yes").action(async (cmdOptions) => {
|
|
1207
|
+
try {
|
|
1208
|
+
await runClean({
|
|
1209
|
+
root: cmdOptions.root,
|
|
1210
|
+
outDir: cmdOptions.out,
|
|
1211
|
+
yes: Boolean(cmdOptions.yes)
|
|
1212
|
+
});
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1215
|
+
console.error(`ForgeLens clean failed: ${message}`);
|
|
1216
|
+
process.exitCode = 1;
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
var promptCommand = program.command("prompt").description("Print copy-ready prompts for AI coding agents").addHelpText("after", "\nExample:\n forgelens prompt codex");
|
|
1220
|
+
promptCommand.command("codex").description("Print prompt text for Codex using ForgeLens context files").option("--out <path>", "context folder path", ".forgelens").action((cmdOptions) => {
|
|
1221
|
+
console.log(buildCodexPrompt(cmdOptions.out));
|
|
1222
|
+
});
|
|
1223
|
+
void program.parseAsync(process.argv);
|