@haystackeditor/cli 0.8.1 → 0.9.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/README.md +46 -0
- package/dist/assets/hooks/llm-rules-template.md +21 -0
- package/dist/assets/hooks/scripts/pre-push.sh +20 -0
- package/dist/assets/skills/prepare-haystack.md +323 -0
- package/dist/assets/skills/secrets.md +164 -0
- package/dist/assets/skills/setup-external-sandbox.md +243 -0
- package/dist/assets/skills/setup-haystack.md +639 -0
- package/dist/assets/skills/submit.md +154 -0
- package/dist/assets/templates/CLAUDE.md.snippet +42 -0
- package/dist/assets/templates/haystack.yml +193 -0
- package/dist/commands/check-pending.d.ts +19 -0
- package/dist/commands/check-pending.js +217 -0
- package/dist/commands/config.d.ts +13 -21
- package/dist/commands/config.js +278 -92
- package/dist/commands/install-session-hooks.d.ts +16 -0
- package/dist/commands/install-session-hooks.js +302 -0
- package/dist/commands/login.js +1 -1
- package/dist/commands/policy.d.ts +31 -0
- package/dist/commands/policy.js +365 -0
- package/dist/commands/skills.d.ts +2 -2
- package/dist/commands/skills.js +51 -186
- package/dist/commands/submit.d.ts +22 -0
- package/dist/commands/submit.js +428 -0
- package/dist/commands/triage.d.ts +16 -0
- package/dist/commands/triage.js +354 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +232 -2
- package/dist/tools/detect.d.ts +50 -0
- package/dist/tools/detect.js +853 -0
- package/dist/tools/fixtures.d.ts +38 -0
- package/dist/tools/fixtures.js +199 -0
- package/dist/tools/setup.d.ts +43 -0
- package/dist/tools/setup.js +597 -0
- package/dist/triage/prompts.d.ts +31 -0
- package/dist/triage/prompts.js +266 -0
- package/dist/triage/runner.d.ts +21 -0
- package/dist/triage/runner.js +325 -0
- package/dist/triage/traces.d.ts +20 -0
- package/dist/triage/traces.js +305 -0
- package/dist/triage/types.d.ts +47 -0
- package/dist/triage/types.js +7 -0
- package/dist/types.d.ts +1387 -191
- package/dist/types.js +254 -2
- package/dist/utils/analysis-api.d.ts +108 -0
- package/dist/utils/analysis-api.js +194 -0
- package/dist/utils/config.js +1 -1
- package/dist/utils/git.d.ts +80 -0
- package/dist/utils/git.js +302 -0
- package/dist/utils/github-api.d.ts +83 -0
- package/dist/utils/github-api.js +266 -0
- package/dist/utils/pending-state.d.ts +38 -0
- package/dist/utils/pending-state.js +86 -0
- package/dist/utils/secrets.js +3 -3
- package/dist/utils/skill.js +257 -0
- package/package.json +4 -2
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { globSync } from 'glob';
|
|
4
|
+
const FRAMEWORK_PATTERNS = [
|
|
5
|
+
// Vite
|
|
6
|
+
{
|
|
7
|
+
files: ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'],
|
|
8
|
+
framework: {
|
|
9
|
+
name: 'vite',
|
|
10
|
+
devCommand: 'pnpm dev',
|
|
11
|
+
port: 5173,
|
|
12
|
+
readyPattern: 'Local:',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
// Next.js
|
|
16
|
+
{
|
|
17
|
+
files: ['next.config.js', 'next.config.mjs', 'next.config.ts'],
|
|
18
|
+
framework: {
|
|
19
|
+
name: 'nextjs',
|
|
20
|
+
devCommand: 'pnpm dev',
|
|
21
|
+
port: 3000,
|
|
22
|
+
readyPattern: 'Ready in',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
// Remix
|
|
26
|
+
{
|
|
27
|
+
files: ['remix.config.js', 'remix.config.ts'],
|
|
28
|
+
framework: {
|
|
29
|
+
name: 'remix',
|
|
30
|
+
devCommand: 'pnpm dev',
|
|
31
|
+
port: 3000,
|
|
32
|
+
readyPattern: 'started',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
// Nuxt
|
|
36
|
+
{
|
|
37
|
+
files: ['nuxt.config.ts', 'nuxt.config.js'],
|
|
38
|
+
framework: {
|
|
39
|
+
name: 'nuxt',
|
|
40
|
+
devCommand: 'pnpm dev',
|
|
41
|
+
port: 3000,
|
|
42
|
+
readyPattern: 'Listening',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
// SvelteKit
|
|
46
|
+
{
|
|
47
|
+
files: ['svelte.config.js', 'svelte.config.ts'],
|
|
48
|
+
framework: {
|
|
49
|
+
name: 'sveltekit',
|
|
50
|
+
devCommand: 'pnpm dev',
|
|
51
|
+
port: 5173,
|
|
52
|
+
readyPattern: 'Local:',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
// Astro
|
|
56
|
+
{
|
|
57
|
+
files: ['astro.config.mjs', 'astro.config.ts'],
|
|
58
|
+
framework: {
|
|
59
|
+
name: 'astro',
|
|
60
|
+
devCommand: 'pnpm dev',
|
|
61
|
+
port: 4321,
|
|
62
|
+
readyPattern: 'Local',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
// Create React App
|
|
66
|
+
{
|
|
67
|
+
files: ['react-scripts'], // Check in package.json dependencies
|
|
68
|
+
framework: {
|
|
69
|
+
name: 'create-react-app',
|
|
70
|
+
devCommand: 'npm start',
|
|
71
|
+
port: 3000,
|
|
72
|
+
readyPattern: 'Compiled',
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
// Cloudflare Workers (Wrangler)
|
|
76
|
+
{
|
|
77
|
+
files: ['wrangler.toml', 'wrangler.json'],
|
|
78
|
+
framework: {
|
|
79
|
+
name: 'cloudflare-worker',
|
|
80
|
+
devCommand: 'pnpm wrangler dev --local',
|
|
81
|
+
port: 8787,
|
|
82
|
+
readyPattern: 'Ready on',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
// Express/Node.js (generic)
|
|
86
|
+
{
|
|
87
|
+
files: ['server.js', 'server.ts', 'app.js', 'app.ts'],
|
|
88
|
+
framework: {
|
|
89
|
+
name: 'node-server',
|
|
90
|
+
devCommand: 'pnpm dev',
|
|
91
|
+
port: 3000,
|
|
92
|
+
readyPattern: 'listening',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
const MONOREPO_INDICATORS = [
|
|
97
|
+
'pnpm-workspace.yaml',
|
|
98
|
+
'lerna.json',
|
|
99
|
+
'nx.json',
|
|
100
|
+
'turbo.json',
|
|
101
|
+
'rush.json',
|
|
102
|
+
];
|
|
103
|
+
const PACKAGE_MANAGER_LOCKFILES = {
|
|
104
|
+
'pnpm-lock.yaml': 'pnpm',
|
|
105
|
+
'yarn.lock': 'yarn',
|
|
106
|
+
'package-lock.json': 'npm',
|
|
107
|
+
'bun.lockb': 'bun',
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Detect the package manager from lockfiles
|
|
111
|
+
*/
|
|
112
|
+
function detectPackageManager(rootDir) {
|
|
113
|
+
for (const [lockfile, manager] of Object.entries(PACKAGE_MANAGER_LOCKFILES)) {
|
|
114
|
+
if (existsSync(join(rootDir, lockfile))) {
|
|
115
|
+
return manager;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return 'npm';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Check if a file exists in a directory
|
|
122
|
+
*/
|
|
123
|
+
function hasFile(dir, filename) {
|
|
124
|
+
return existsSync(join(dir, filename));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Detect framework from files in a directory
|
|
128
|
+
*/
|
|
129
|
+
function detectFramework(dir) {
|
|
130
|
+
for (const pattern of FRAMEWORK_PATTERNS) {
|
|
131
|
+
for (const file of pattern.files) {
|
|
132
|
+
if (hasFile(dir, file)) {
|
|
133
|
+
return {
|
|
134
|
+
...pattern.framework,
|
|
135
|
+
configFile: file,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Check package.json for react-scripts (CRA)
|
|
141
|
+
const pkgPath = join(dir, 'package.json');
|
|
142
|
+
if (existsSync(pkgPath)) {
|
|
143
|
+
try {
|
|
144
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
145
|
+
if (pkg.dependencies?.['react-scripts'] || pkg.devDependencies?.['react-scripts']) {
|
|
146
|
+
return {
|
|
147
|
+
name: 'create-react-app',
|
|
148
|
+
devCommand: 'npm start',
|
|
149
|
+
port: 3000,
|
|
150
|
+
readyPattern: 'Compiled',
|
|
151
|
+
configFile: 'package.json',
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Ignore parse errors
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Detect if project is a monorepo
|
|
163
|
+
*/
|
|
164
|
+
function detectMonorepo(rootDir) {
|
|
165
|
+
return MONOREPO_INDICATORS.some((file) => hasFile(rootDir, file));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Find workspace packages in a pnpm monorepo
|
|
169
|
+
*/
|
|
170
|
+
function findPnpmWorkspaces(rootDir) {
|
|
171
|
+
const workspaceFile = join(rootDir, 'pnpm-workspace.yaml');
|
|
172
|
+
if (!existsSync(workspaceFile))
|
|
173
|
+
return [];
|
|
174
|
+
try {
|
|
175
|
+
const content = readFileSync(workspaceFile, 'utf-8');
|
|
176
|
+
const lines = content.split('\n');
|
|
177
|
+
const packages = [];
|
|
178
|
+
let inPackages = false;
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
// Check for packages: section start
|
|
181
|
+
if (line.match(/^packages:/)) {
|
|
182
|
+
inPackages = true;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// New section starts (non-indented, non-comment line)
|
|
186
|
+
if (inPackages && line.match(/^[a-z]/)) {
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
// Parse package entries (handles comments between entries)
|
|
190
|
+
if (inPackages) {
|
|
191
|
+
const trimmed = line.trim();
|
|
192
|
+
if (trimmed.startsWith('-') && !trimmed.startsWith('#')) {
|
|
193
|
+
// Extract package path, removing quotes and inline comments
|
|
194
|
+
const pkg = trimmed
|
|
195
|
+
.replace(/^-\s*['"]?/, '')
|
|
196
|
+
.replace(/['"]?\s*$/, '')
|
|
197
|
+
.replace(/#.*/, '')
|
|
198
|
+
.trim();
|
|
199
|
+
if (pkg)
|
|
200
|
+
packages.push(pkg);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return packages;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Expand glob patterns to actual directories
|
|
212
|
+
*/
|
|
213
|
+
function expandWorkspaceGlobs(rootDir, patterns) {
|
|
214
|
+
const dirs = [];
|
|
215
|
+
for (const pattern of patterns) {
|
|
216
|
+
// Remove trailing /* or /**
|
|
217
|
+
const cleanPattern = pattern.replace(/\/\*+$/, '');
|
|
218
|
+
try {
|
|
219
|
+
// Use glob to find matches
|
|
220
|
+
const matches = globSync(cleanPattern, { cwd: rootDir });
|
|
221
|
+
// Filter to only directories
|
|
222
|
+
for (const match of matches) {
|
|
223
|
+
const fullPath = join(rootDir, match);
|
|
224
|
+
try {
|
|
225
|
+
if (statSync(fullPath).isDirectory()) {
|
|
226
|
+
dirs.push(match);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Skip if stat fails
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
// If glob fails, try as literal path
|
|
236
|
+
if (existsSync(join(rootDir, cleanPattern))) {
|
|
237
|
+
dirs.push(cleanPattern);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return [...new Set(dirs)];
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Detect services in a monorepo
|
|
245
|
+
*/
|
|
246
|
+
function detectServices(rootDir) {
|
|
247
|
+
const services = [];
|
|
248
|
+
// Find workspace packages
|
|
249
|
+
const workspacePatterns = findPnpmWorkspaces(rootDir);
|
|
250
|
+
const packageDirs = expandWorkspaceGlobs(rootDir, workspacePatterns);
|
|
251
|
+
// Also check common directories
|
|
252
|
+
const commonDirs = [
|
|
253
|
+
'packages',
|
|
254
|
+
'apps',
|
|
255
|
+
'services',
|
|
256
|
+
'infra',
|
|
257
|
+
'workers',
|
|
258
|
+
'functions',
|
|
259
|
+
'api',
|
|
260
|
+
'web',
|
|
261
|
+
'frontend',
|
|
262
|
+
'backend',
|
|
263
|
+
];
|
|
264
|
+
for (const dir of commonDirs) {
|
|
265
|
+
if (existsSync(join(rootDir, dir)) && !packageDirs.includes(dir)) {
|
|
266
|
+
packageDirs.push(dir);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Detect framework in each directory
|
|
270
|
+
for (const dir of packageDirs) {
|
|
271
|
+
const fullPath = join(rootDir, dir);
|
|
272
|
+
if (!existsSync(fullPath))
|
|
273
|
+
continue;
|
|
274
|
+
// Check if this directory has its own package.json (is a package)
|
|
275
|
+
if (!hasFile(fullPath, 'package.json'))
|
|
276
|
+
continue;
|
|
277
|
+
const framework = detectFramework(fullPath);
|
|
278
|
+
const name = dir.split('/').pop() || dir;
|
|
279
|
+
services.push({
|
|
280
|
+
name,
|
|
281
|
+
root: dir,
|
|
282
|
+
type: framework?.name === 'cloudflare-worker' ? 'server' : 'server',
|
|
283
|
+
framework: framework || undefined,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
// Also check the root directory
|
|
287
|
+
const rootFramework = detectFramework(rootDir);
|
|
288
|
+
if (rootFramework) {
|
|
289
|
+
services.unshift({
|
|
290
|
+
name: 'frontend',
|
|
291
|
+
root: './',
|
|
292
|
+
type: 'server',
|
|
293
|
+
framework: rootFramework,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return services;
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Get project name from package.json
|
|
300
|
+
*/
|
|
301
|
+
function getProjectName(rootDir) {
|
|
302
|
+
const pkgPath = join(rootDir, 'package.json');
|
|
303
|
+
if (existsSync(pkgPath)) {
|
|
304
|
+
try {
|
|
305
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
306
|
+
return pkg.name || 'my-app';
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// Ignore
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return 'my-app';
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Detect common auth bypass patterns
|
|
316
|
+
*/
|
|
317
|
+
function detectAuthBypass(rootDir) {
|
|
318
|
+
// Check for common env var patterns in code
|
|
319
|
+
const envFiles = ['.env.example', '.env.sample', '.env.template'];
|
|
320
|
+
for (const envFile of envFiles) {
|
|
321
|
+
const path = join(rootDir, envFile);
|
|
322
|
+
if (existsSync(path)) {
|
|
323
|
+
try {
|
|
324
|
+
const content = readFileSync(path, 'utf-8');
|
|
325
|
+
// Look for auth-related env vars
|
|
326
|
+
const authPatterns = [
|
|
327
|
+
/^(SKIP_AUTH|BYPASS_AUTH|DISABLE_AUTH|VITE_SKIP_AUTH|NEXT_PUBLIC_SKIP_AUTH)=/m,
|
|
328
|
+
/^(AUTH_DISABLED|NO_AUTH|DEV_AUTH)=/m,
|
|
329
|
+
];
|
|
330
|
+
for (const pattern of authPatterns) {
|
|
331
|
+
const match = content.match(pattern);
|
|
332
|
+
if (match) {
|
|
333
|
+
return `${match[1]}=true`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
// Ignore
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Extract localhost URLs and ports from a string.
|
|
346
|
+
* Handles patterns like: http://localhost:8787, localhost:3001, 127.0.0.1:8080
|
|
347
|
+
*/
|
|
348
|
+
function extractLocalhostTargets(content, pathPattern) {
|
|
349
|
+
const targets = [];
|
|
350
|
+
const urlRegex = /(?:https?:\/\/)?(?:localhost|127\.0\.0\.1):(\d+)/g;
|
|
351
|
+
let match;
|
|
352
|
+
while ((match = urlRegex.exec(content)) !== null) {
|
|
353
|
+
const port = parseInt(match[1], 10);
|
|
354
|
+
const target = match[0].startsWith('http') ? match[0] : `http://${match[0]}`;
|
|
355
|
+
// Avoid duplicates
|
|
356
|
+
if (!targets.some((t) => t.port === port)) {
|
|
357
|
+
targets.push({
|
|
358
|
+
path: pathPattern || '/api',
|
|
359
|
+
target,
|
|
360
|
+
port,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
return targets;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Detect proxy targets from various config files.
|
|
368
|
+
* Supports: Vite, Next.js, Create React App, Vercel, Webpack
|
|
369
|
+
* This helps identify backend services the frontend depends on.
|
|
370
|
+
*/
|
|
371
|
+
function detectProxyTargets(dir) {
|
|
372
|
+
const targets = [];
|
|
373
|
+
const seenPorts = new Set();
|
|
374
|
+
const addTarget = (target) => {
|
|
375
|
+
if (!seenPorts.has(target.port)) {
|
|
376
|
+
seenPorts.add(target.port);
|
|
377
|
+
targets.push(target);
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
// 1. Vite - vite.config.ts/js with server.proxy
|
|
381
|
+
const viteConfigs = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
|
|
382
|
+
for (const configFile of viteConfigs) {
|
|
383
|
+
const configPath = join(dir, configFile);
|
|
384
|
+
if (existsSync(configPath)) {
|
|
385
|
+
try {
|
|
386
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
387
|
+
// Look for proxy patterns like: '/api': { target: 'http://localhost:8787' }
|
|
388
|
+
const proxyMatches = content.matchAll(/['"]([^'"]+)['"]\s*:\s*(?:\{[^}]*target\s*:\s*['"]([^'"]+)['"]|['"]([^'"]+)['"])/g);
|
|
389
|
+
for (const match of proxyMatches) {
|
|
390
|
+
const path = match[1];
|
|
391
|
+
const target = match[2] || match[3];
|
|
392
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
393
|
+
const portMatch = target.match(/:(\d+)/);
|
|
394
|
+
if (portMatch) {
|
|
395
|
+
addTarget({
|
|
396
|
+
path,
|
|
397
|
+
target,
|
|
398
|
+
port: parseInt(portMatch[1], 10),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Ignore parse errors
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
// 2. Next.js - next.config.js/mjs/ts with rewrites()
|
|
410
|
+
const nextConfigs = ['next.config.js', 'next.config.mjs', 'next.config.ts'];
|
|
411
|
+
for (const configFile of nextConfigs) {
|
|
412
|
+
const configPath = join(dir, configFile);
|
|
413
|
+
if (existsSync(configPath)) {
|
|
414
|
+
try {
|
|
415
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
416
|
+
// Look for rewrite patterns: { source: '/api/:path*', destination: 'http://localhost:3001/:path*' }
|
|
417
|
+
const rewriteMatches = content.matchAll(/destination\s*:\s*['"`]([^'"`]+)['"`]/g);
|
|
418
|
+
for (const match of rewriteMatches) {
|
|
419
|
+
const destination = match[1];
|
|
420
|
+
if (destination.includes('localhost') || destination.includes('127.0.0.1')) {
|
|
421
|
+
const portMatch = destination.match(/:(\d+)/);
|
|
422
|
+
if (portMatch) {
|
|
423
|
+
// Try to find the associated source
|
|
424
|
+
const sourceMatch = content.match(new RegExp(`source\\s*:\\s*['"\`]([^'"\`]+)['"\`][^}]*destination\\s*:\\s*['"\`]${destination.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
|
|
425
|
+
addTarget({
|
|
426
|
+
path: sourceMatch?.[1] || '/api',
|
|
427
|
+
target: destination.replace(/\/:[^/]+\*?/g, ''), // Remove path params
|
|
428
|
+
port: parseInt(portMatch[1], 10),
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
// Ignore parse errors
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// 3. Create React App - package.json with "proxy" field
|
|
440
|
+
const pkgPath = join(dir, 'package.json');
|
|
441
|
+
if (existsSync(pkgPath)) {
|
|
442
|
+
try {
|
|
443
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
444
|
+
if (typeof pkg.proxy === 'string') {
|
|
445
|
+
const portMatch = pkg.proxy.match(/:(\d+)/);
|
|
446
|
+
if (portMatch) {
|
|
447
|
+
addTarget({
|
|
448
|
+
path: '/api',
|
|
449
|
+
target: pkg.proxy,
|
|
450
|
+
port: parseInt(portMatch[1], 10),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
// Ignore parse errors
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// 4. Create React App - setupProxy.js (http-proxy-middleware)
|
|
460
|
+
const setupProxyPath = join(dir, 'src', 'setupProxy.js');
|
|
461
|
+
if (existsSync(setupProxyPath)) {
|
|
462
|
+
try {
|
|
463
|
+
const content = readFileSync(setupProxyPath, 'utf-8');
|
|
464
|
+
// Look for: createProxyMiddleware('/api', { target: 'http://localhost:3001' })
|
|
465
|
+
const proxyMatches = content.matchAll(/createProxyMiddleware\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*target\s*:\s*['"]([^'"]+)['"]/g);
|
|
466
|
+
for (const match of proxyMatches) {
|
|
467
|
+
const path = match[1];
|
|
468
|
+
const target = match[2];
|
|
469
|
+
const portMatch = target.match(/:(\d+)/);
|
|
470
|
+
if (portMatch) {
|
|
471
|
+
addTarget({
|
|
472
|
+
path,
|
|
473
|
+
target,
|
|
474
|
+
port: parseInt(portMatch[1], 10),
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
// Ignore parse errors
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// 5. Vercel - vercel.json with rewrites
|
|
484
|
+
const vercelPath = join(dir, 'vercel.json');
|
|
485
|
+
if (existsSync(vercelPath)) {
|
|
486
|
+
try {
|
|
487
|
+
const vercelConfig = JSON.parse(readFileSync(vercelPath, 'utf-8'));
|
|
488
|
+
if (Array.isArray(vercelConfig.rewrites)) {
|
|
489
|
+
for (const rewrite of vercelConfig.rewrites) {
|
|
490
|
+
if (rewrite.destination && (rewrite.destination.includes('localhost') || rewrite.destination.includes('127.0.0.1'))) {
|
|
491
|
+
const portMatch = rewrite.destination.match(/:(\d+)/);
|
|
492
|
+
if (portMatch) {
|
|
493
|
+
addTarget({
|
|
494
|
+
path: rewrite.source || '/api',
|
|
495
|
+
target: rewrite.destination,
|
|
496
|
+
port: parseInt(portMatch[1], 10),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Ignore parse errors
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// 6. Webpack - webpack.config.js with devServer.proxy
|
|
508
|
+
const webpackConfigs = ['webpack.config.js', 'webpack.config.ts'];
|
|
509
|
+
for (const configFile of webpackConfigs) {
|
|
510
|
+
const configPath = join(dir, configFile);
|
|
511
|
+
if (existsSync(configPath)) {
|
|
512
|
+
try {
|
|
513
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
514
|
+
// Look for devServer.proxy patterns
|
|
515
|
+
const proxyMatches = content.matchAll(/['"]([^'"]+)['"]\s*:\s*(?:\{[^}]*target\s*:\s*['"]([^'"]+)['"]|['"]([^'"]+)['"])/g);
|
|
516
|
+
for (const match of proxyMatches) {
|
|
517
|
+
const path = match[1];
|
|
518
|
+
const target = match[2] || match[3];
|
|
519
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
520
|
+
const portMatch = target.match(/:(\d+)/);
|
|
521
|
+
if (portMatch) {
|
|
522
|
+
addTarget({
|
|
523
|
+
path,
|
|
524
|
+
target,
|
|
525
|
+
port: parseInt(portMatch[1], 10),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Ignore parse errors
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// 7. Vue CLI - vue.config.js with devServer.proxy (same pattern as webpack)
|
|
537
|
+
const vueConfigPath = join(dir, 'vue.config.js');
|
|
538
|
+
if (existsSync(vueConfigPath)) {
|
|
539
|
+
try {
|
|
540
|
+
const content = readFileSync(vueConfigPath, 'utf-8');
|
|
541
|
+
const proxyMatches = content.matchAll(/['"]([^'"]+)['"]\s*:\s*(?:\{[^}]*target\s*:\s*['"]([^'"]+)['"]|['"]([^'"]+)['"])/g);
|
|
542
|
+
for (const match of proxyMatches) {
|
|
543
|
+
const path = match[1];
|
|
544
|
+
const target = match[2] || match[3];
|
|
545
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
546
|
+
const portMatch = target.match(/:(\d+)/);
|
|
547
|
+
if (portMatch) {
|
|
548
|
+
addTarget({
|
|
549
|
+
path,
|
|
550
|
+
target,
|
|
551
|
+
port: parseInt(portMatch[1], 10),
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Ignore parse errors
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// 8. Angular - proxy.conf.json or proxy.conf.js
|
|
562
|
+
const angularProxyConfigs = ['proxy.conf.json', 'proxy.conf.js'];
|
|
563
|
+
for (const configFile of angularProxyConfigs) {
|
|
564
|
+
const configPath = join(dir, configFile);
|
|
565
|
+
if (existsSync(configPath)) {
|
|
566
|
+
try {
|
|
567
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
568
|
+
if (configFile.endsWith('.json')) {
|
|
569
|
+
// JSON format: { "/api": { "target": "http://localhost:3000" } }
|
|
570
|
+
const config = JSON.parse(content);
|
|
571
|
+
for (const [path, settings] of Object.entries(config)) {
|
|
572
|
+
const target = settings?.target;
|
|
573
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
574
|
+
const portMatch = target.match(/:(\d+)/);
|
|
575
|
+
if (portMatch) {
|
|
576
|
+
addTarget({
|
|
577
|
+
path,
|
|
578
|
+
target,
|
|
579
|
+
port: parseInt(portMatch[1], 10),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
// JS format - use regex
|
|
587
|
+
const proxyMatches = content.matchAll(/['"]([^'"]+)['"]\s*:\s*\{[^}]*target\s*:\s*['"]([^'"]+)['"]/g);
|
|
588
|
+
for (const match of proxyMatches) {
|
|
589
|
+
const path = match[1];
|
|
590
|
+
const target = match[2];
|
|
591
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
592
|
+
const portMatch = target.match(/:(\d+)/);
|
|
593
|
+
if (portMatch) {
|
|
594
|
+
addTarget({
|
|
595
|
+
path,
|
|
596
|
+
target,
|
|
597
|
+
port: parseInt(portMatch[1], 10),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
// Ignore parse errors
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// 9. Nuxt.js - nuxt.config.ts/js with routeRules or proxy
|
|
610
|
+
const nuxtConfigs = ['nuxt.config.ts', 'nuxt.config.js'];
|
|
611
|
+
for (const configFile of nuxtConfigs) {
|
|
612
|
+
const configPath = join(dir, configFile);
|
|
613
|
+
if (existsSync(configPath)) {
|
|
614
|
+
try {
|
|
615
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
616
|
+
// Look for proxy or routeRules with proxy settings
|
|
617
|
+
const proxyMatches = content.matchAll(/(?:proxy|target)\s*:\s*['"]([^'"]+)['"]/g);
|
|
618
|
+
for (const match of proxyMatches) {
|
|
619
|
+
const target = match[1];
|
|
620
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
621
|
+
const portMatch = target.match(/:(\d+)/);
|
|
622
|
+
if (portMatch) {
|
|
623
|
+
addTarget({
|
|
624
|
+
path: '/api',
|
|
625
|
+
target,
|
|
626
|
+
port: parseInt(portMatch[1], 10),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// Ignore parse errors
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// 10. Gatsby - gatsby-config.js with proxy
|
|
638
|
+
const gatsbyConfigPath = join(dir, 'gatsby-config.js');
|
|
639
|
+
if (existsSync(gatsbyConfigPath)) {
|
|
640
|
+
try {
|
|
641
|
+
const content = readFileSync(gatsbyConfigPath, 'utf-8');
|
|
642
|
+
// Look for developMiddleware proxy patterns
|
|
643
|
+
const proxyMatches = content.matchAll(/proxy\s*:\s*\{[^}]*url\s*:\s*['"]([^'"]+)['"]/g);
|
|
644
|
+
for (const match of proxyMatches) {
|
|
645
|
+
const target = match[1];
|
|
646
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
647
|
+
const portMatch = target.match(/:(\d+)/);
|
|
648
|
+
if (portMatch) {
|
|
649
|
+
addTarget({
|
|
650
|
+
path: '/api',
|
|
651
|
+
target,
|
|
652
|
+
port: parseInt(portMatch[1], 10),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
catch {
|
|
659
|
+
// Ignore parse errors
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// 11. Parcel - .proxyrc or .proxyrc.json
|
|
663
|
+
const parcelProxyConfigs = ['.proxyrc', '.proxyrc.json'];
|
|
664
|
+
for (const configFile of parcelProxyConfigs) {
|
|
665
|
+
const configPath = join(dir, configFile);
|
|
666
|
+
if (existsSync(configPath)) {
|
|
667
|
+
try {
|
|
668
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
669
|
+
const config = JSON.parse(content);
|
|
670
|
+
// Parcel format: { "/api": { "target": "http://localhost:3000" } }
|
|
671
|
+
for (const [path, settings] of Object.entries(config)) {
|
|
672
|
+
const target = settings?.target;
|
|
673
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
674
|
+
const portMatch = target.match(/:(\d+)/);
|
|
675
|
+
if (portMatch) {
|
|
676
|
+
addTarget({
|
|
677
|
+
path,
|
|
678
|
+
target,
|
|
679
|
+
port: parseInt(portMatch[1], 10),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
catch {
|
|
686
|
+
// Ignore parse errors
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// 12. Netlify - netlify.toml with redirects
|
|
691
|
+
const netlifyConfigPath = join(dir, 'netlify.toml');
|
|
692
|
+
if (existsSync(netlifyConfigPath)) {
|
|
693
|
+
try {
|
|
694
|
+
const content = readFileSync(netlifyConfigPath, 'utf-8');
|
|
695
|
+
// Look for redirect rules with localhost: [[redirects]] from = "/api/*" to = "http://localhost:3000/:splat"
|
|
696
|
+
const redirectMatches = content.matchAll(/\[\[redirects\]\][^[]*from\s*=\s*["']([^"']+)["'][^[]*to\s*=\s*["']([^"']+)["']/gs);
|
|
697
|
+
for (const match of redirectMatches) {
|
|
698
|
+
const path = match[1];
|
|
699
|
+
const target = match[2];
|
|
700
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
701
|
+
const portMatch = target.match(/:(\d+)/);
|
|
702
|
+
if (portMatch) {
|
|
703
|
+
addTarget({
|
|
704
|
+
path,
|
|
705
|
+
target,
|
|
706
|
+
port: parseInt(portMatch[1], 10),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
// Ignore parse errors
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// 13. Remix - remix.config.js doesn't have proxy, but check for serverBuildTarget
|
|
717
|
+
// Remix uses loaders, so no proxy detection needed
|
|
718
|
+
// 14. Esbuild/custom - check for common proxy middleware in server files
|
|
719
|
+
const serverFiles = ['server.js', 'server.ts', 'dev-server.js', 'dev-server.ts'];
|
|
720
|
+
for (const serverFile of serverFiles) {
|
|
721
|
+
const serverPath = join(dir, serverFile);
|
|
722
|
+
if (existsSync(serverPath)) {
|
|
723
|
+
try {
|
|
724
|
+
const content = readFileSync(serverPath, 'utf-8');
|
|
725
|
+
// Look for http-proxy-middleware or manual proxy setups
|
|
726
|
+
const proxyMatches = content.matchAll(/(?:createProxyMiddleware|proxy)\s*\(\s*['"]([^'"]+)['"]\s*,\s*\{[^}]*target\s*:\s*['"]([^'"]+)['"]/g);
|
|
727
|
+
for (const match of proxyMatches) {
|
|
728
|
+
const path = match[1];
|
|
729
|
+
const target = match[2];
|
|
730
|
+
if (target && (target.includes('localhost') || target.includes('127.0.0.1'))) {
|
|
731
|
+
const portMatch = target.match(/:(\d+)/);
|
|
732
|
+
if (portMatch) {
|
|
733
|
+
addTarget({
|
|
734
|
+
path,
|
|
735
|
+
target,
|
|
736
|
+
port: parseInt(portMatch[1], 10),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
// Ignore parse errors
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return targets;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Match proxy targets to detected services by port.
|
|
751
|
+
* Returns service names that match the proxy targets.
|
|
752
|
+
*/
|
|
753
|
+
function matchProxyToServices(proxyTargets, services) {
|
|
754
|
+
const dependsOn = [];
|
|
755
|
+
for (const proxy of proxyTargets) {
|
|
756
|
+
// Find a service running on this port
|
|
757
|
+
const matchingService = services.find((s) => s.framework?.port === proxy.port);
|
|
758
|
+
if (matchingService && !dependsOn.includes(matchingService.name)) {
|
|
759
|
+
dependsOn.push(matchingService.name);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return dependsOn;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Main detection function - analyzes a project directory
|
|
766
|
+
*/
|
|
767
|
+
export function detectProject(rootDir = process.cwd()) {
|
|
768
|
+
const isMonorepo = detectMonorepo(rootDir);
|
|
769
|
+
const packageManager = detectPackageManager(rootDir);
|
|
770
|
+
const projectName = getProjectName(rootDir);
|
|
771
|
+
let frameworks = [];
|
|
772
|
+
let services = [];
|
|
773
|
+
let proxyTargets = [];
|
|
774
|
+
if (isMonorepo) {
|
|
775
|
+
services = detectServices(rootDir);
|
|
776
|
+
frameworks = services
|
|
777
|
+
.filter((s) => s.framework)
|
|
778
|
+
.map((s) => s.framework);
|
|
779
|
+
// Detect proxy targets from root config
|
|
780
|
+
proxyTargets = detectProxyTargets(rootDir);
|
|
781
|
+
// Match proxy targets to services and set depends_on
|
|
782
|
+
if (proxyTargets.length > 0) {
|
|
783
|
+
const dependsOn = matchProxyToServices(proxyTargets, services);
|
|
784
|
+
// Find the frontend/main service and add depends_on
|
|
785
|
+
const frontendService = services.find((s) => s.root === './' || s.root === '.' || s.name === 'frontend');
|
|
786
|
+
if (frontendService && dependsOn.length > 0) {
|
|
787
|
+
frontendService.depends_on = dependsOn;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
const framework = detectFramework(rootDir);
|
|
793
|
+
if (framework) {
|
|
794
|
+
frameworks = [framework];
|
|
795
|
+
}
|
|
796
|
+
proxyTargets = detectProxyTargets(rootDir);
|
|
797
|
+
}
|
|
798
|
+
// Build suggestions based on detection
|
|
799
|
+
const primaryFramework = frameworks[0];
|
|
800
|
+
const runPrefix = packageManager === 'npm' ? 'npm run' : packageManager;
|
|
801
|
+
const suggestions = {
|
|
802
|
+
projectName,
|
|
803
|
+
devCommand: primaryFramework?.devCommand || `${runPrefix} dev`,
|
|
804
|
+
port: primaryFramework?.port || 3000,
|
|
805
|
+
readyPattern: primaryFramework?.readyPattern || 'ready|started|listening',
|
|
806
|
+
authBypassEnv: detectAuthBypass(rootDir),
|
|
807
|
+
};
|
|
808
|
+
return {
|
|
809
|
+
isMonorepo,
|
|
810
|
+
packageManager,
|
|
811
|
+
frameworks,
|
|
812
|
+
services,
|
|
813
|
+
proxyTargets,
|
|
814
|
+
suggestions,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Format detection results for display to user
|
|
819
|
+
*/
|
|
820
|
+
export function formatDetectionSummary(detection) {
|
|
821
|
+
const lines = [];
|
|
822
|
+
lines.push(`Project: ${detection.suggestions.projectName}`);
|
|
823
|
+
lines.push(`Package Manager: ${detection.packageManager}`);
|
|
824
|
+
lines.push(`Monorepo: ${detection.isMonorepo ? 'Yes' : 'No'}`);
|
|
825
|
+
if (detection.frameworks.length > 0) {
|
|
826
|
+
lines.push(`\nDetected Frameworks:`);
|
|
827
|
+
for (const fw of detection.frameworks) {
|
|
828
|
+
lines.push(` - ${fw.name} (${fw.configFile})`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
if (detection.isMonorepo && detection.services.length > 0) {
|
|
832
|
+
lines.push(`\nDetected Services:`);
|
|
833
|
+
for (const svc of detection.services) {
|
|
834
|
+
const fw = svc.framework ? ` (${svc.framework.name})` : '';
|
|
835
|
+
const deps = svc.depends_on?.length ? ` → depends on: ${svc.depends_on.join(', ')}` : '';
|
|
836
|
+
lines.push(` - ${svc.name}: ${svc.root}${fw}${deps}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
if (detection.proxyTargets.length > 0) {
|
|
840
|
+
lines.push(`\nDetected Proxy Targets:`);
|
|
841
|
+
for (const proxy of detection.proxyTargets) {
|
|
842
|
+
lines.push(` - ${proxy.path} → ${proxy.target}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
lines.push(`\nSuggested Settings:`);
|
|
846
|
+
lines.push(` Dev Command: ${detection.suggestions.devCommand}`);
|
|
847
|
+
lines.push(` Port: ${detection.suggestions.port}`);
|
|
848
|
+
lines.push(` Ready Pattern: "${detection.suggestions.readyPattern}"`);
|
|
849
|
+
if (detection.suggestions.authBypassEnv) {
|
|
850
|
+
lines.push(` Auth Bypass: ${detection.suggestions.authBypassEnv}`);
|
|
851
|
+
}
|
|
852
|
+
return lines.join('\n');
|
|
853
|
+
}
|