@drewpayment/mink 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.
Files changed (72) hide show
  1. package/README.md +347 -0
  2. package/package.json +32 -0
  3. package/src/cli.ts +176 -0
  4. package/src/commands/bug-search.ts +32 -0
  5. package/src/commands/config.ts +109 -0
  6. package/src/commands/cron.ts +295 -0
  7. package/src/commands/daemon.ts +46 -0
  8. package/src/commands/dashboard.ts +21 -0
  9. package/src/commands/designqc.ts +160 -0
  10. package/src/commands/detect-waste.ts +81 -0
  11. package/src/commands/framework-advisor.ts +52 -0
  12. package/src/commands/init.ts +159 -0
  13. package/src/commands/post-read.ts +123 -0
  14. package/src/commands/post-write.ts +157 -0
  15. package/src/commands/pre-read.ts +109 -0
  16. package/src/commands/pre-write.ts +136 -0
  17. package/src/commands/reflect.ts +39 -0
  18. package/src/commands/restore.ts +31 -0
  19. package/src/commands/scan.ts +101 -0
  20. package/src/commands/session-start.ts +21 -0
  21. package/src/commands/session-stop.ts +115 -0
  22. package/src/commands/status.ts +152 -0
  23. package/src/commands/update.ts +121 -0
  24. package/src/core/action-log.ts +341 -0
  25. package/src/core/backup.ts +122 -0
  26. package/src/core/bug-memory.ts +223 -0
  27. package/src/core/cron-parser.ts +94 -0
  28. package/src/core/daemon.ts +152 -0
  29. package/src/core/dashboard-api.ts +280 -0
  30. package/src/core/dashboard-server.ts +580 -0
  31. package/src/core/description.ts +232 -0
  32. package/src/core/design-eval/capture.ts +269 -0
  33. package/src/core/design-eval/route-detect.ts +165 -0
  34. package/src/core/design-eval/server-detect.ts +91 -0
  35. package/src/core/framework-advisor/catalog.ts +360 -0
  36. package/src/core/framework-advisor/decision-tree.ts +287 -0
  37. package/src/core/framework-advisor/generate.ts +132 -0
  38. package/src/core/framework-advisor/migration-prompts.ts +502 -0
  39. package/src/core/framework-advisor/validate.ts +137 -0
  40. package/src/core/fs-utils.ts +30 -0
  41. package/src/core/global-config.ts +74 -0
  42. package/src/core/index-store.ts +72 -0
  43. package/src/core/learning-memory.ts +120 -0
  44. package/src/core/paths.ts +86 -0
  45. package/src/core/pattern-engine.ts +108 -0
  46. package/src/core/project-id.ts +19 -0
  47. package/src/core/project-registry.ts +64 -0
  48. package/src/core/reflection.ts +256 -0
  49. package/src/core/scanner.ts +99 -0
  50. package/src/core/scheduler.ts +352 -0
  51. package/src/core/seed.ts +239 -0
  52. package/src/core/session.ts +128 -0
  53. package/src/core/stdin.ts +13 -0
  54. package/src/core/task-registry.ts +202 -0
  55. package/src/core/token-estimate.ts +36 -0
  56. package/src/core/token-ledger.ts +185 -0
  57. package/src/core/waste-detection.ts +214 -0
  58. package/src/core/write-exclusions.ts +24 -0
  59. package/src/types/action-log.ts +20 -0
  60. package/src/types/backup.ts +6 -0
  61. package/src/types/bug-memory.ts +24 -0
  62. package/src/types/config.ts +59 -0
  63. package/src/types/dashboard.ts +104 -0
  64. package/src/types/design-eval.ts +64 -0
  65. package/src/types/file-index.ts +38 -0
  66. package/src/types/framework-advisor.ts +97 -0
  67. package/src/types/hook-input.ts +27 -0
  68. package/src/types/learning-memory.ts +36 -0
  69. package/src/types/scheduler.ts +82 -0
  70. package/src/types/session.ts +50 -0
  71. package/src/types/token-ledger.ts +43 -0
  72. package/src/types/waste-detection.ts +21 -0
@@ -0,0 +1,232 @@
1
+ import { basename, extname } from "path";
2
+
3
+ const MAX_DESCRIPTION_LENGTH = 100;
4
+
5
+ const CONFIG_DESCRIPTIONS: Record<string, string> = {
6
+ "package.json": "Node.js package manifest",
7
+ "tsconfig.json": "TypeScript configuration",
8
+ "tsconfig.node.json": "TypeScript configuration (Node)",
9
+ "tailwind.config.js": "Tailwind CSS configuration",
10
+ "tailwind.config.ts": "Tailwind CSS configuration",
11
+ "vite.config.js": "Vite build configuration",
12
+ "vite.config.ts": "Vite build configuration",
13
+ "next.config.js": "Next.js configuration",
14
+ "next.config.ts": "Next.js configuration",
15
+ "next.config.mjs": "Next.js configuration",
16
+ "eslint.config.js": "ESLint configuration",
17
+ "eslint.config.mjs": "ESLint configuration",
18
+ ".eslintrc": "ESLint configuration",
19
+ ".eslintrc.js": "ESLint configuration",
20
+ ".eslintrc.json": "ESLint configuration",
21
+ ".prettierrc": "Prettier configuration",
22
+ ".prettierrc.json": "Prettier configuration",
23
+ "prettier.config.js": "Prettier configuration",
24
+ "Dockerfile": "Docker container definition",
25
+ "docker-compose.yml": "Docker Compose services",
26
+ "docker-compose.yaml": "Docker Compose services",
27
+ "Makefile": "Make build targets",
28
+ "CMakeLists.txt": "CMake build configuration",
29
+ "Cargo.toml": "Rust package manifest",
30
+ "go.mod": "Go module definition",
31
+ "pyproject.toml": "Python project configuration",
32
+ "setup.py": "Python package setup",
33
+ "Gemfile": "Ruby dependency manifest",
34
+ "composer.json": "PHP package manifest",
35
+ "build.gradle": "Gradle build configuration",
36
+ "pom.xml": "Maven build configuration",
37
+ "bunfig.toml": "Bun configuration",
38
+ };
39
+
40
+ function truncate(str: string): string {
41
+ if (str.length <= MAX_DESCRIPTION_LENGTH) return str;
42
+ return str.slice(0, MAX_DESCRIPTION_LENGTH - 3) + "...";
43
+ }
44
+
45
+ function hasBinaryContent(content: string): boolean {
46
+ return content.includes("\0");
47
+ }
48
+
49
+ function extractMarkdownHeading(content: string): string | null {
50
+ const match = content.match(/^#\s+(.+)$/m);
51
+ return match ? match[1].trim() : null;
52
+ }
53
+
54
+ function extractHtmlTitle(content: string): string | null {
55
+ const match = content.match(/<title[^>]*>([^<]+)<\/title>/i);
56
+ return match ? match[1].trim() : null;
57
+ }
58
+
59
+ function extractDocComment(content: string): string | null {
60
+ // JSDoc / JavaDoc style: /** ... */
61
+ const jsdoc = content.match(/^\/\*\*\s*\n?\s*\*?\s*(.+)/m);
62
+ if (jsdoc) return jsdoc[1].replace(/\*\/\s*$/, "").trim();
63
+
64
+ // Python docstring: """...""" or '''...'''
65
+ const pydoc = content.match(/^(?:def |class ).*\n\s*(?:"""|''')(.+)/m);
66
+ if (pydoc) return pydoc[1].trim();
67
+
68
+ // Shell/Ruby/Python top-of-file comment block
69
+ const lines = content.split("\n");
70
+ if (lines[0]?.startsWith("#!")) {
71
+ // Skip shebang, look at next comment
72
+ for (let i = 1; i < Math.min(lines.length, 5); i++) {
73
+ const line = lines[i].trim();
74
+ if (line.startsWith("# ") && line.length > 2) {
75
+ return line.slice(2).trim();
76
+ }
77
+ if (line && !line.startsWith("#")) break;
78
+ }
79
+ } else if (lines[0]?.startsWith("# ") && lines[0].length > 2) {
80
+ return lines[0].slice(2).trim();
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function extractExports(content: string): string | null {
87
+ const exports: string[] = [];
88
+ const re = /export\s+(?:function|const|class|interface|type|enum)\s+(\w+)/g;
89
+ let match: RegExpExecArray | null;
90
+ while ((match = re.exec(content)) !== null) {
91
+ exports.push(match[1]);
92
+ }
93
+ if (exports.length === 0) return null;
94
+ return `exports: ${exports.join(", ")}`;
95
+ }
96
+
97
+ function extractComponent(content: string, filePath: string): string | null {
98
+ const ext = extname(filePath).toLowerCase();
99
+ if (![".tsx", ".jsx", ".vue", ".svelte"].includes(ext)) return null;
100
+
101
+ // Look for a PascalCase named export (component convention)
102
+ const namedMatch = content.match(
103
+ /(?:export\s+(?:default\s+)?function|export\s+const)\s+([A-Z]\w+)/
104
+ );
105
+ const componentName = namedMatch
106
+ ? namedMatch[1]
107
+ : basename(filePath, ext);
108
+
109
+ const elements: string[] = [];
110
+ if (/<form[\s>]/i.test(content)) elements.push("form");
111
+ if (/<table[\s>]/i.test(content)) elements.push("table");
112
+ if (/modal/i.test(content)) elements.push("modal");
113
+ if (/<ul[\s>]|<ol[\s>]|<li[\s>]/i.test(content)) elements.push("list");
114
+ if (/<input[\s>]|<textarea[\s>]|<select[\s>]/i.test(content))
115
+ elements.push("inputs");
116
+
117
+ if (elements.length === 0) return null;
118
+ return `${componentName} — renders ${elements.join(", ")}`;
119
+ }
120
+
121
+ function extractCiWorkflow(content: string, filePath: string): string | null {
122
+ const normalized = filePath.replace(/\\/g, "/");
123
+ const isCi =
124
+ normalized.includes(".github/workflows/") ||
125
+ normalized.includes(".gitlab-ci") ||
126
+ basename(filePath).toLowerCase() === "jenkinsfile";
127
+ if (!isCi) return null;
128
+
129
+ const nameMatch = content.match(/^name:\s*(.+)$/m);
130
+ if (nameMatch) return `CI: ${nameMatch[1].trim()}`;
131
+ return `CI: ${basename(filePath)}`;
132
+ }
133
+
134
+ function extractMigration(content: string, filePath: string): string | null {
135
+ const normalized = filePath.toLowerCase();
136
+ const isMigration =
137
+ normalized.includes("migration") || normalized.includes("migrate");
138
+ if (!isMigration) return null;
139
+
140
+ const tableMatch = content.match(
141
+ /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(\w+)/i
142
+ );
143
+ if (tableMatch) return `migration: create ${tableMatch[1]}`;
144
+ return `migration: ${basename(filePath)}`;
145
+ }
146
+
147
+ function extractFallback(content: string): string | null {
148
+ const lines = content.split("\n");
149
+ for (const line of lines) {
150
+ const trimmed = line.trim();
151
+ if (trimmed && !trimmed.startsWith("//") && !trimmed.startsWith("#")) {
152
+ return trimmed;
153
+ }
154
+ }
155
+ return null;
156
+ }
157
+
158
+ export function extractDescription(filePath: string, content: string): string {
159
+ const name = basename(filePath);
160
+ const ext = extname(filePath).toLowerCase();
161
+
162
+ // Edge cases first
163
+ if (content.length === 0) return `${name} — empty file`;
164
+ if (hasBinaryContent(content)) return `${name} — binary file`;
165
+
166
+ let description: string | null = null;
167
+ const isLargeFile = content.length > 100 * 1024;
168
+
169
+ // Priority 1: Markdown heading
170
+ if ([".md", ".mdx"].includes(ext)) {
171
+ description = extractMarkdownHeading(content);
172
+ }
173
+
174
+ // Priority 2: HTML title
175
+ if (!description && [".html", ".htm"].includes(ext)) {
176
+ description = extractHtmlTitle(content);
177
+ }
178
+
179
+ // Priority 3: Doc comment
180
+ if (!description) {
181
+ description = extractDocComment(content);
182
+ }
183
+
184
+ // Priority 4/5: For component files (tsx/jsx/vue/svelte), try component
185
+ // detection before generic exports so element-rich components get
186
+ // meaningful descriptions instead of bare export lists.
187
+ const componentExt = [".tsx", ".jsx", ".vue", ".svelte"];
188
+ if (!description && componentExt.includes(extname(filePath).toLowerCase())) {
189
+ description = extractComponent(content, filePath);
190
+ }
191
+
192
+ // Priority 4: Exports (for non-component files, or component files without elements)
193
+ if (!description) {
194
+ description = extractExports(content);
195
+ }
196
+
197
+ // Priority 5 (non-component fallback): already handled above for component files
198
+
199
+ // Priority 6: Known config file
200
+ if (!description) {
201
+ const configDesc = CONFIG_DESCRIPTIONS[name];
202
+ if (configDesc) description = configDesc;
203
+ }
204
+
205
+ // Priority 7: CI/CD
206
+ if (!description) {
207
+ description = extractCiWorkflow(content, filePath);
208
+ }
209
+
210
+ // Priority 8: Migration
211
+ if (!description) {
212
+ description = extractMigration(content, filePath);
213
+ }
214
+
215
+ // Priority 9: Fallback
216
+ if (!description) {
217
+ description = extractFallback(content);
218
+ }
219
+
220
+ // Final fallback
221
+ if (!description) {
222
+ description = name;
223
+ }
224
+
225
+ if (isLargeFile) {
226
+ description = truncate(description + " (large file)");
227
+ } else {
228
+ description = truncate(description);
229
+ }
230
+
231
+ return description;
232
+ }
@@ -0,0 +1,269 @@
1
+ import { mkdirSync, statSync, existsSync } from "fs";
2
+ import { join } from "path";
3
+ import puppeteer from "puppeteer-core";
4
+ import type { Browser, Page } from "puppeteer-core";
5
+ import { minkRoot } from "../paths";
6
+ import type {
7
+ Viewport,
8
+ CaptureResult,
9
+ DesignEvalReport,
10
+ DesignQcOptions,
11
+ } from "../../types/design-eval";
12
+
13
+ // ── Browser Detection ─────────────────────────────────────────────────────
14
+
15
+ const CHROME_PATHS: Record<string, string[]> = {
16
+ darwin: [
17
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
18
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
19
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
20
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
21
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
22
+ ],
23
+ linux: [
24
+ "/usr/bin/google-chrome",
25
+ "/usr/bin/google-chrome-stable",
26
+ "/usr/bin/chromium",
27
+ "/usr/bin/chromium-browser",
28
+ "/snap/bin/chromium",
29
+ ],
30
+ win32: [
31
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
32
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
33
+ `${process.env.LOCALAPPDATA ?? ""}\\Google\\Chrome\\Application\\chrome.exe`,
34
+ ],
35
+ };
36
+
37
+ /**
38
+ * Find a Chrome/Chromium executable on the system.
39
+ * Checks platform-specific paths, then ~/.mink/browsers/.
40
+ */
41
+ export function findBrowser(): string {
42
+ const platform = process.platform;
43
+
44
+ // Check system paths
45
+ const paths = CHROME_PATHS[platform] ?? [];
46
+ for (const p of paths) {
47
+ if (existsSync(p)) return p;
48
+ }
49
+
50
+ // Check mink-managed browser installs
51
+ const minkBrowsers = join(minkRoot(), "browsers");
52
+ if (existsSync(minkBrowsers)) {
53
+ // Look for chrome/chromium executables recursively
54
+ const found = findChromeInDir(minkBrowsers);
55
+ if (found) return found;
56
+ }
57
+
58
+ throw new Error(
59
+ [
60
+ "[mink] No Chrome/Chromium browser found.",
61
+ "",
62
+ "Install one of:",
63
+ " • Google Chrome: https://www.google.com/chrome/",
64
+ " • Or run: npx @puppeteer/browsers install chrome@stable --path ~/.mink/browsers",
65
+ "",
66
+ "Then retry: mink designqc",
67
+ ].join("\n")
68
+ );
69
+ }
70
+
71
+ function findChromeInDir(dir: string): string | null {
72
+ const { readdirSync, statSync } = require("fs") as typeof import("fs");
73
+ try {
74
+ const entries = readdirSync(dir);
75
+ for (const entry of entries) {
76
+ const full = join(dir, entry);
77
+ try {
78
+ const stat = statSync(full);
79
+ if (stat.isDirectory()) {
80
+ const found = findChromeInDir(full);
81
+ if (found) return found;
82
+ } else if (/^(chrome|chromium|Google Chrome)$/i.test(entry)) {
83
+ return full;
84
+ }
85
+ } catch {
86
+ continue;
87
+ }
88
+ }
89
+ } catch {
90
+ // dir not readable
91
+ }
92
+ return null;
93
+ }
94
+
95
+ // ── Route Sanitization ────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Convert a route path to a safe filename prefix.
99
+ * "/" → "index", "/about" → "about", "/foo/bar" → "foo-bar"
100
+ */
101
+ export function sanitizeRoute(route: string): string {
102
+ if (route === "/") return "index";
103
+ return route
104
+ .replace(/^\//, "")
105
+ .replace(/\/+/g, "-")
106
+ .replace(/[^a-zA-Z0-9-_]/g, "_");
107
+ }
108
+
109
+ /**
110
+ * Calculate how many viewport-height sections are needed.
111
+ */
112
+ export function calculateSections(
113
+ pageHeight: number,
114
+ viewportHeight: number,
115
+ maxSections: number
116
+ ): number {
117
+ if (pageHeight <= 0) return 0;
118
+ return Math.min(Math.ceil(pageHeight / viewportHeight), maxSections);
119
+ }
120
+
121
+ // ── Screenshot Capture ────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Capture a single route at a single viewport, producing sectioned screenshots.
125
+ */
126
+ export async function captureRoute(
127
+ page: Page,
128
+ route: string,
129
+ baseUrl: string,
130
+ viewport: Viewport,
131
+ options: { quality: number; maxSections: number; outputDir: string }
132
+ ): Promise<CaptureResult[]> {
133
+ const results: CaptureResult[] = [];
134
+ const url = `${baseUrl.replace(/\/$/, "")}${route}`;
135
+ const timestamp = new Date().toISOString();
136
+
137
+ // Set viewport
138
+ await page.setViewport({ width: viewport.width, height: viewport.height });
139
+
140
+ // Navigate and wait for stability
141
+ const response = await page.goto(url, {
142
+ waitUntil: "networkidle0",
143
+ timeout: 30000,
144
+ });
145
+ const statusCode = response?.status() ?? 0;
146
+
147
+ // Get full page height
148
+ const pageHeight = await page.evaluate(() => {
149
+ return Math.max(
150
+ document.body.scrollHeight,
151
+ document.documentElement.scrollHeight
152
+ );
153
+ });
154
+
155
+ const totalSections = calculateSections(
156
+ pageHeight,
157
+ viewport.height,
158
+ options.maxSections
159
+ );
160
+
161
+ if (totalSections === 0) return results;
162
+
163
+ const prefix = sanitizeRoute(route);
164
+
165
+ for (let section = 0; section < totalSections; section++) {
166
+ const y = section * viewport.height;
167
+ const clipHeight = Math.min(viewport.height, pageHeight - y);
168
+
169
+ const fileName = `${prefix}-${viewport.name}-${section}.jpg`;
170
+ const filePath = join(options.outputDir, fileName);
171
+
172
+ await page.screenshot({
173
+ path: filePath,
174
+ type: "jpeg",
175
+ quality: options.quality,
176
+ clip: {
177
+ x: 0,
178
+ y,
179
+ width: viewport.width,
180
+ height: clipHeight,
181
+ },
182
+ });
183
+
184
+ let fileSize = 0;
185
+ try {
186
+ fileSize = statSync(filePath).size;
187
+ } catch {
188
+ // stat failed — leave as 0
189
+ }
190
+
191
+ results.push({
192
+ route,
193
+ viewport: viewport.name,
194
+ section,
195
+ totalSections,
196
+ filePath,
197
+ fileName,
198
+ fileSize,
199
+ pageHeight,
200
+ statusCode,
201
+ timestamp,
202
+ });
203
+ }
204
+
205
+ return results;
206
+ }
207
+
208
+ /**
209
+ * Capture all routes across all viewports. Launches the browser once.
210
+ */
211
+ export async function captureAllRoutes(
212
+ routes: string[],
213
+ baseUrl: string,
214
+ viewports: Viewport[],
215
+ options: DesignQcOptions,
216
+ outputDir: string
217
+ ): Promise<DesignEvalReport> {
218
+ mkdirSync(outputDir, { recursive: true });
219
+
220
+ const executablePath = findBrowser();
221
+ const browser: Browser = await puppeteer.launch({
222
+ executablePath,
223
+ headless: true,
224
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
225
+ });
226
+
227
+ const report: DesignEvalReport = {
228
+ capturedAt: new Date().toISOString(),
229
+ serverUrl: baseUrl,
230
+ routes,
231
+ viewports,
232
+ quality: options.quality,
233
+ captures: [],
234
+ errors: [],
235
+ };
236
+
237
+ try {
238
+ const page = await browser.newPage();
239
+
240
+ for (const route of routes) {
241
+ for (const viewport of viewports) {
242
+ try {
243
+ const results = await captureRoute(page, route, baseUrl, viewport, {
244
+ quality: options.quality,
245
+ maxSections: options.maxSections,
246
+ outputDir,
247
+ });
248
+ report.captures.push(...results);
249
+ } catch (err) {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ report.errors.push({
252
+ route,
253
+ viewport: viewport.name,
254
+ error: message,
255
+ });
256
+ console.error(
257
+ `[mink] Error capturing ${route} (${viewport.name}): ${message}`
258
+ );
259
+ }
260
+ }
261
+ }
262
+
263
+ await page.close();
264
+ } finally {
265
+ await browser.close();
266
+ }
267
+
268
+ return report;
269
+ }
@@ -0,0 +1,165 @@
1
+ import { existsSync, readdirSync, statSync } from "fs";
2
+ import { join, relative, sep } from "path";
3
+
4
+ export type FrameworkType = "nextjs" | "sveltekit" | "nuxt" | "generic";
5
+
6
+ /**
7
+ * Detect the frontend framework by checking for config files.
8
+ */
9
+ export function detectFramework(cwd: string): FrameworkType {
10
+ const has = (name: string) =>
11
+ ["js", "mjs", "ts", "cjs"].some((ext) =>
12
+ existsSync(join(cwd, `${name}.${ext}`))
13
+ ) || existsSync(join(cwd, name));
14
+
15
+ if (has("next.config")) return "nextjs";
16
+ if (has("svelte.config")) return "sveltekit";
17
+ if (has("nuxt.config")) return "nuxt";
18
+ return "generic";
19
+ }
20
+
21
+ /**
22
+ * Detect capturable routes based on the project's framework conventions.
23
+ * Only returns static routes — dynamic segments like [param] are excluded.
24
+ */
25
+ export function detectRoutes(cwd: string): string[] {
26
+ const framework = detectFramework(cwd);
27
+
28
+ switch (framework) {
29
+ case "nextjs":
30
+ return detectNextRoutes(cwd);
31
+ case "sveltekit":
32
+ return detectSvelteKitRoutes(cwd);
33
+ case "nuxt":
34
+ return detectNuxtRoutes(cwd);
35
+ case "generic":
36
+ default:
37
+ return ["/"];
38
+ }
39
+ }
40
+
41
+ // ── Next.js ───────────────────────────────────────────────────────────────
42
+
43
+ function detectNextRoutes(cwd: string): string[] {
44
+ const routes: string[] = [];
45
+
46
+ // App Router: app/**/page.{tsx,jsx,js,ts}
47
+ const appDir = join(cwd, "app");
48
+ if (existsSync(appDir)) {
49
+ const pageFiles = findFiles(appDir, /^page\.(tsx?|jsx?)$/);
50
+ for (const file of pageFiles) {
51
+ const rel = relative(appDir, file);
52
+ const dir = rel.replace(/([/\\])?page\.(tsx?|jsx?)$/, "");
53
+ const route = dir === "" ? "/" : `/${dir.split(sep).join("/")}`;
54
+
55
+ // Skip dynamic segments, route groups, and parallel routes
56
+ if (/\[|@|\(/.test(route)) continue;
57
+ routes.push(route);
58
+ }
59
+ }
60
+
61
+ // Pages Router: pages/**/*.{tsx,jsx,js,ts}
62
+ const pagesDir = join(cwd, "pages");
63
+ if (existsSync(pagesDir)) {
64
+ const pageFiles = findFiles(pagesDir, /\.(tsx?|jsx?)$/);
65
+ for (const file of pageFiles) {
66
+ const rel = relative(pagesDir, file);
67
+ const name = rel.replace(/\.(tsx?|jsx?)$/, "");
68
+
69
+ // Skip special Next.js files and API routes
70
+ if (/^_(app|document|error)/.test(name)) continue;
71
+ if (name.startsWith(`api${sep}`) || name === "api") continue;
72
+ // Skip dynamic segments
73
+ if (/\[/.test(name)) continue;
74
+
75
+ const route =
76
+ name === "index" ? "/" : `/${name.split(sep).join("/")}`;
77
+ routes.push(route);
78
+ }
79
+ }
80
+
81
+ // Deduplicate (app router and pages router may both define /)
82
+ const unique = [...new Set(routes)];
83
+ return unique.length > 0 ? unique.sort() : ["/"];
84
+ }
85
+
86
+ // ── SvelteKit ─────────────────────────────────────────────────────────────
87
+
88
+ function detectSvelteKitRoutes(cwd: string): string[] {
89
+ const routesDir = join(cwd, "src", "routes");
90
+ if (!existsSync(routesDir)) return ["/"];
91
+
92
+ const routes: string[] = [];
93
+ const pageFiles = findFiles(routesDir, /^\+page\.svelte$/);
94
+
95
+ for (const file of pageFiles) {
96
+ const rel = relative(routesDir, file);
97
+ const dir = rel.replace(/([/\\])?\+page\.svelte$/, "");
98
+ const route = dir === "" ? "/" : `/${dir.split(sep).join("/")}`;
99
+
100
+ // Skip dynamic segments and groups
101
+ if (/\[|\(/.test(route)) continue;
102
+ routes.push(route);
103
+ }
104
+
105
+ return routes.length > 0 ? routes.sort() : ["/"];
106
+ }
107
+
108
+ // ── Nuxt ──────────────────────────────────────────────────────────────────
109
+
110
+ function detectNuxtRoutes(cwd: string): string[] {
111
+ const pagesDir = join(cwd, "pages");
112
+ if (!existsSync(pagesDir)) return ["/"];
113
+
114
+ const routes: string[] = [];
115
+ const vueFiles = findFiles(pagesDir, /\.vue$/);
116
+
117
+ for (const file of vueFiles) {
118
+ const rel = relative(pagesDir, file);
119
+ const name = rel.replace(/\.vue$/, "");
120
+
121
+ // Skip dynamic segments
122
+ if (/\[/.test(name)) continue;
123
+
124
+ const route =
125
+ name === "index" ? "/" : `/${name.split(sep).join("/")}`;
126
+ routes.push(route);
127
+ }
128
+
129
+ return routes.length > 0 ? routes.sort() : ["/"];
130
+ }
131
+
132
+ // ── Helpers ───────────────────────────────────────────────────────────────
133
+
134
+ function findFiles(dir: string, pattern: RegExp): string[] {
135
+ const results: string[] = [];
136
+
137
+ function walk(current: string): void {
138
+ let entries: string[];
139
+ try {
140
+ entries = readdirSync(current);
141
+ } catch {
142
+ return;
143
+ }
144
+
145
+ for (const entry of entries) {
146
+ // Skip hidden dirs and node_modules
147
+ if (entry.startsWith(".") || entry === "node_modules") continue;
148
+
149
+ const full = join(current, entry);
150
+ try {
151
+ const stat = statSync(full);
152
+ if (stat.isDirectory()) {
153
+ walk(full);
154
+ } else if (pattern.test(entry)) {
155
+ results.push(full);
156
+ }
157
+ } catch {
158
+ // Skip inaccessible entries
159
+ }
160
+ }
161
+ }
162
+
163
+ walk(dir);
164
+ return results;
165
+ }