@haystackeditor/cli 0.18.0 → 0.20.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.
@@ -1,507 +0,0 @@
1
- /**
2
- * Project detection utilities
3
- *
4
- * Auto-detects framework, package manager, monorepo structure, services,
5
- * and inter-service dependencies from proxy configurations.
6
- */
7
- import * as fs from 'fs/promises';
8
- import * as path from 'path';
9
- /**
10
- * Detect project configuration
11
- */
12
- export async function detectProject(rootDir = process.cwd()) {
13
- const result = {
14
- packageManager: await detectPackageManager(rootDir),
15
- isMonorepo: false,
16
- };
17
- // Detect monorepo
18
- const monorepo = await detectMonorepo(rootDir);
19
- if (monorepo) {
20
- result.isMonorepo = true;
21
- result.monorepoTool = monorepo.tool;
22
- result.services = await detectServices(rootDir, monorepo.workspaces);
23
- // Detect proxy dependencies and add them to services
24
- if (result.services && result.services.length > 0) {
25
- result.services = await suggestServiceDependencies(rootDir, result.services);
26
- }
27
- }
28
- // Detect framework
29
- result.framework = await detectFramework(rootDir);
30
- // Extract custom port from config if framework supports it
31
- const customPort = await extractPortFromConfig(rootDir, result.framework);
32
- if (customPort) {
33
- result.suggestedPort = customPort;
34
- }
35
- // Set suggestions based on framework (won't override customPort if set)
36
- setSuggestions(result);
37
- // Detect auth bypass from .env.example
38
- result.suggestedAuthBypass = await detectAuthBypass(rootDir);
39
- return result;
40
- }
41
- /**
42
- * Detect package manager from lockfiles
43
- */
44
- async function detectPackageManager(rootDir) {
45
- const checks = [
46
- { file: 'pnpm-lock.yaml', manager: 'pnpm' },
47
- { file: 'yarn.lock', manager: 'yarn' },
48
- { file: 'bun.lockb', manager: 'bun' },
49
- { file: 'package-lock.json', manager: 'npm' },
50
- ];
51
- for (const { file, manager } of checks) {
52
- try {
53
- await fs.access(path.join(rootDir, file));
54
- return manager;
55
- }
56
- catch {
57
- // Not found, continue
58
- }
59
- }
60
- return 'npm'; // Default
61
- }
62
- /**
63
- * Detect monorepo structure
64
- */
65
- async function detectMonorepo(rootDir) {
66
- // Check pnpm workspaces
67
- try {
68
- const pnpmWorkspace = await fs.readFile(path.join(rootDir, 'pnpm-workspace.yaml'), 'utf-8');
69
- const match = pnpmWorkspace.match(/packages:\s*\n([\s\S]*?)(?:\n\w|$)/);
70
- if (match) {
71
- const workspaces = match[1]
72
- .split('\n')
73
- .map((line) => line.trim().replace(/^-\s*['"]?|['"]?$/g, ''))
74
- .filter(Boolean);
75
- return { tool: 'pnpm-workspaces', workspaces };
76
- }
77
- }
78
- catch {
79
- // Not found
80
- }
81
- // Check package.json workspaces (yarn/npm)
82
- try {
83
- const pkgJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf-8'));
84
- if (pkgJson.workspaces) {
85
- const workspaces = Array.isArray(pkgJson.workspaces)
86
- ? pkgJson.workspaces
87
- : pkgJson.workspaces.packages || [];
88
- return { tool: 'pnpm-workspaces', workspaces };
89
- }
90
- }
91
- catch {
92
- // Not found
93
- }
94
- // Check for nx.json
95
- try {
96
- await fs.access(path.join(rootDir, 'nx.json'));
97
- return { tool: 'nx', workspaces: ['packages/*', 'apps/*'] };
98
- }
99
- catch {
100
- // Not found
101
- }
102
- // Check for turbo.json
103
- try {
104
- await fs.access(path.join(rootDir, 'turbo.json'));
105
- return { tool: 'turborepo', workspaces: ['packages/*', 'apps/*'] };
106
- }
107
- catch {
108
- // Not found
109
- }
110
- // Check for lerna.json
111
- try {
112
- await fs.access(path.join(rootDir, 'lerna.json'));
113
- return { tool: 'lerna', workspaces: ['packages/*'] };
114
- }
115
- catch {
116
- // Not found
117
- }
118
- return null;
119
- }
120
- /**
121
- * Detect services in a monorepo
122
- */
123
- async function detectServices(rootDir, workspacePatterns) {
124
- const services = [];
125
- const glob = await import('fast-glob');
126
- for (const pattern of workspacePatterns) {
127
- const matches = await glob.default(pattern, {
128
- cwd: rootDir,
129
- onlyDirectories: true,
130
- ignore: ['node_modules'],
131
- });
132
- for (const match of matches) {
133
- const service = await detectService(rootDir, match);
134
- if (service) {
135
- services.push(service);
136
- }
137
- }
138
- }
139
- // Also check common directories
140
- const commonDirs = ['infra', 'services', 'apps', 'packages'];
141
- for (const dir of commonDirs) {
142
- try {
143
- const entries = await fs.readdir(path.join(rootDir, dir), {
144
- withFileTypes: true,
145
- });
146
- for (const entry of entries) {
147
- if (entry.isDirectory()) {
148
- const servicePath = path.join(dir, entry.name);
149
- // Skip if already detected
150
- if (services.some((s) => s.root === servicePath))
151
- continue;
152
- const service = await detectService(rootDir, servicePath);
153
- if (service) {
154
- services.push(service);
155
- }
156
- }
157
- }
158
- }
159
- catch {
160
- // Directory doesn't exist
161
- }
162
- }
163
- return services;
164
- }
165
- /**
166
- * Detect a single service
167
- */
168
- async function detectService(rootDir, servicePath) {
169
- const fullPath = path.join(rootDir, servicePath);
170
- // Check for package.json
171
- let pkgJson = null;
172
- try {
173
- pkgJson = JSON.parse(await fs.readFile(path.join(fullPath, 'package.json'), 'utf-8'));
174
- }
175
- catch {
176
- return null; // No package.json, not a service
177
- }
178
- const name = pkgJson?.name?.replace(/^@[^/]+\//, '') || path.basename(servicePath);
179
- const scripts = pkgJson?.scripts || {};
180
- // Detect if it's a Cloudflare Worker
181
- const isWorker = await hasFile(fullPath, 'wrangler.toml') ||
182
- await hasFile(fullPath, 'wrangler.jsonc') ||
183
- await hasFile(fullPath, 'wrangler.json');
184
- // Detect framework
185
- let framework;
186
- let suggestedCommand;
187
- let suggestedPort;
188
- if (isWorker) {
189
- framework = 'cloudflare-worker';
190
- suggestedCommand = 'pnpm wrangler dev --local';
191
- suggestedPort = 8787;
192
- }
193
- else if (await hasFile(fullPath, 'vite.config.ts') || await hasFile(fullPath, 'vite.config.js')) {
194
- framework = 'vite';
195
- suggestedCommand = 'pnpm dev';
196
- // Try to extract custom port from vite config, fallback to default
197
- suggestedPort = await extractPortFromConfig(fullPath, 'vite') ?? 5173;
198
- }
199
- else if (await hasFile(fullPath, 'next.config.js') || await hasFile(fullPath, 'next.config.ts')) {
200
- framework = 'nextjs';
201
- suggestedCommand = 'pnpm dev';
202
- // Try to extract custom port from next config, fallback to default
203
- suggestedPort = await extractPortFromConfig(fullPath, 'nextjs') ?? 3000;
204
- }
205
- else if (scripts.dev) {
206
- suggestedCommand = 'pnpm dev';
207
- suggestedPort = 3000;
208
- }
209
- else if (scripts.start) {
210
- suggestedCommand = 'pnpm start';
211
- }
212
- // Determine if it's a batch job or server
213
- const type = name.includes('analysis') || name.includes('batch') || name.includes('job')
214
- ? 'batch'
215
- : 'server';
216
- return {
217
- name,
218
- root: servicePath,
219
- type,
220
- framework,
221
- suggestedCommand,
222
- suggestedPort: type === 'server' ? suggestedPort : undefined,
223
- };
224
- }
225
- /**
226
- * Detect framework from config files
227
- */
228
- async function detectFramework(rootDir) {
229
- const checks = [
230
- { files: ['vite.config.ts', 'vite.config.js'], framework: 'vite' },
231
- { files: ['next.config.js', 'next.config.ts', 'next.config.mjs'], framework: 'nextjs' },
232
- { files: ['remix.config.js', 'remix.config.ts'], framework: 'remix' },
233
- { files: ['nuxt.config.ts', 'nuxt.config.js'], framework: 'nuxt' },
234
- { files: ['svelte.config.js', 'svelte.config.ts'], framework: 'sveltekit' },
235
- { files: ['astro.config.mjs', 'astro.config.ts'], framework: 'astro' },
236
- { files: ['wrangler.toml', 'wrangler.jsonc'], framework: 'cloudflare-worker' },
237
- ];
238
- for (const { files, framework } of checks) {
239
- for (const file of files) {
240
- if (await hasFile(rootDir, file)) {
241
- return framework;
242
- }
243
- }
244
- }
245
- // Check for CRA
246
- try {
247
- const pkgJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf-8'));
248
- if (pkgJson.dependencies?.['react-scripts']) {
249
- return 'cra';
250
- }
251
- }
252
- catch {
253
- // Not found
254
- }
255
- return undefined;
256
- }
257
- /**
258
- * Detect auth bypass env var from .env.example
259
- */
260
- async function detectAuthBypass(rootDir) {
261
- const envFiles = ['.env.example', '.env.local.example', '.env.development'];
262
- for (const file of envFiles) {
263
- try {
264
- const content = await fs.readFile(path.join(rootDir, file), 'utf-8');
265
- // Common patterns
266
- const patterns = [
267
- /^(SKIP_AUTH)=/m,
268
- /^(BYPASS_AUTH)=/m,
269
- /^(AUTH_DISABLED)=/m,
270
- /^(MOCK_AUTH)=/m,
271
- /^(NEXT_PUBLIC_MOCK_AUTH)=/m,
272
- /^(VITE_MOCK_AUTH)=/m,
273
- /^(VITE_SKIP_AUTH)=/m,
274
- ];
275
- for (const pattern of patterns) {
276
- const match = content.match(pattern);
277
- if (match) {
278
- return `${match[1]}=true`;
279
- }
280
- }
281
- }
282
- catch {
283
- // File not found
284
- }
285
- }
286
- // No evidence of an auth-bypass variable — suggest nothing rather than
287
- // fabricating a SKIP_AUTH the app doesn't read.
288
- return undefined;
289
- }
290
- /**
291
- * Set suggestions based on detected framework
292
- * Note: This is sync, so custom port extraction happens in detectProject
293
- */
294
- function setSuggestions(result) {
295
- const frameworkDefaults = {
296
- vite: { command: 'pnpm dev', port: 5173, ready: 'Local:' },
297
- nextjs: { command: 'pnpm dev', port: 3000, ready: 'Ready' },
298
- remix: { command: 'pnpm dev', port: 3000, ready: 'started' },
299
- nuxt: { command: 'pnpm dev', port: 3000, ready: 'Local:' },
300
- sveltekit: { command: 'pnpm dev', port: 5173, ready: 'Local:' },
301
- astro: { command: 'pnpm dev', port: 4321, ready: 'Local:' },
302
- cra: { command: 'pnpm start', port: 3000, ready: 'Compiled' },
303
- 'cloudflare-worker': { command: 'pnpm wrangler dev', port: 8787, ready: 'Ready' },
304
- };
305
- const defaults = result.framework ? frameworkDefaults[result.framework] : null;
306
- result.suggestedDevCommand = defaults?.command || `${result.packageManager} dev`;
307
- // suggestedPort may already be set from custom config extraction
308
- result.suggestedPort = result.suggestedPort || defaults?.port || 3000;
309
- result.suggestedReadyPattern = defaults?.ready || 'ready|started|listening|Local:';
310
- }
311
- /**
312
- * Check if a file exists
313
- */
314
- async function hasFile(dir, file) {
315
- try {
316
- await fs.access(path.join(dir, file));
317
- return true;
318
- }
319
- catch {
320
- return false;
321
- }
322
- }
323
- /**
324
- * Extract custom port from framework config files
325
- *
326
- * Parses vite.config.ts, next.config.js, etc. to find custom port settings
327
- * Returns undefined if no custom port is configured (use framework default)
328
- */
329
- async function extractPortFromConfig(rootDir, framework) {
330
- if (!framework)
331
- return undefined;
332
- // Config files to check based on framework
333
- const configFiles = {
334
- vite: ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'],
335
- nextjs: ['next.config.js', 'next.config.ts', 'next.config.mjs'],
336
- nuxt: ['nuxt.config.ts', 'nuxt.config.js'],
337
- sveltekit: ['svelte.config.js', 'svelte.config.ts'],
338
- astro: ['astro.config.mjs', 'astro.config.ts'],
339
- };
340
- const files = configFiles[framework];
341
- if (!files)
342
- return undefined;
343
- let configContent = null;
344
- for (const file of files) {
345
- try {
346
- configContent = await fs.readFile(path.join(rootDir, file), 'utf-8');
347
- break;
348
- }
349
- catch {
350
- // File not found, try next
351
- }
352
- }
353
- if (!configContent)
354
- return undefined;
355
- // Patterns to match port configuration
356
- // Vite: server: { port: 3000 } or server.port = 3000
357
- // Next: env: { PORT: 3000 } or experimental.serverPort
358
- const patterns = [
359
- /server\s*:\s*\{[^}]*port\s*:\s*(\d+)/s, // server: { port: 3000 }
360
- /server\s*\.\s*port\s*[=:]\s*(\d+)/, // server.port = 3000
361
- /port\s*:\s*(\d+)/, // port: 3000 (generic)
362
- /PORT\s*[=:]\s*['"]?(\d+)['"]?/, // PORT = 3000
363
- ];
364
- for (const pattern of patterns) {
365
- const match = configContent.match(pattern);
366
- if (match) {
367
- const port = parseInt(match[1], 10);
368
- if (port > 0 && port < 65536) {
369
- return port;
370
- }
371
- }
372
- }
373
- return undefined;
374
- }
375
- /**
376
- * Detect proxy dependencies from vite.config.ts/js
377
- *
378
- * Parses the vite config file to find proxy targets that point to localhost,
379
- * which indicate dependencies on other local services.
380
- *
381
- * @example
382
- * // vite.config.ts with proxy
383
- * server: {
384
- * proxy: {
385
- * '/api/review-chat': {
386
- * target: 'http://localhost:8787',
387
- * }
388
- * }
389
- * }
390
- * // Returns: [{ path: '/api/review-chat', port: 8787, host: 'localhost' }]
391
- */
392
- export async function detectProxyDependencies(rootDir) {
393
- const dependencies = [];
394
- // Try vite.config.ts, then vite.config.js
395
- const configFiles = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
396
- let configContent = null;
397
- for (const file of configFiles) {
398
- try {
399
- configContent = await fs.readFile(path.join(rootDir, file), 'utf-8');
400
- break;
401
- }
402
- catch {
403
- // File not found, try next
404
- }
405
- }
406
- if (!configContent) {
407
- return dependencies;
408
- }
409
- // Parse proxy configurations using regex
410
- // Matches patterns like:
411
- // '/api/path': { target: 'http://localhost:8787' }
412
- // '/api/path': 'http://localhost:8787'
413
- // proxy: { '/api/path': { target: 'http://localhost:8787' } }
414
- // Pattern for object-style proxy: '/path': { target: 'http://localhost:PORT' }
415
- const objectProxyRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*target\s*:\s*['"]https?:\/\/(localhost|127\.0\.0\.1):(\d+)['"]/g;
416
- let match;
417
- while ((match = objectProxyRegex.exec(configContent)) !== null) {
418
- dependencies.push({
419
- path: match[1],
420
- host: match[2],
421
- port: parseInt(match[3], 10),
422
- });
423
- }
424
- // Pattern for string-style proxy: '/path': 'http://localhost:PORT'
425
- const stringProxyRegex = /['"]([^'"]+)['"]\s*:\s*['"]https?:\/\/(localhost|127\.0\.0\.1):(\d+)['"]/g;
426
- let stringMatch;
427
- while ((stringMatch = stringProxyRegex.exec(configContent)) !== null) {
428
- // Avoid duplicates from object pattern
429
- const exists = dependencies.some(d => d.path === stringMatch[1] && d.port === parseInt(stringMatch[3], 10));
430
- if (!exists) {
431
- dependencies.push({
432
- path: stringMatch[1],
433
- host: stringMatch[2],
434
- port: parseInt(stringMatch[3], 10),
435
- });
436
- }
437
- }
438
- return dependencies;
439
- }
440
- /**
441
- * Match proxy dependencies to detected services
442
- *
443
- * Given a list of proxy targets (ports) and detected services,
444
- * determines which services are dependencies based on port matching.
445
- */
446
- export function matchProxyToServices(proxyDeps, services) {
447
- // Create a port → service name mapping
448
- const portToService = new Map();
449
- for (const service of services) {
450
- if (service.suggestedPort) {
451
- portToService.set(service.suggestedPort, service.name);
452
- }
453
- }
454
- // For each service that has proxy deps pointing to another service's port,
455
- // record the dependency
456
- const serviceDeps = new Map();
457
- for (const dep of proxyDeps) {
458
- const matchedService = portToService.get(dep.port);
459
- if (matchedService) {
460
- dep.matchedService = matchedService;
461
- }
462
- }
463
- return serviceDeps;
464
- }
465
- /**
466
- * Suggest depends_on for services based on proxy configuration
467
- *
468
- * For the main service (usually frontend), detect if it proxies to other local services
469
- * and suggest those as dependencies.
470
- */
471
- export async function suggestServiceDependencies(rootDir, services) {
472
- // Detect proxy dependencies from vite config
473
- const proxyDeps = await detectProxyDependencies(rootDir);
474
- if (proxyDeps.length === 0) {
475
- return services;
476
- }
477
- // Create port → service mapping
478
- const portToService = new Map();
479
- for (const service of services) {
480
- if (service.suggestedPort) {
481
- portToService.set(service.suggestedPort, service.name);
482
- }
483
- }
484
- // Match proxy ports to services
485
- const dependsOn = [];
486
- for (const dep of proxyDeps) {
487
- const serviceName = portToService.get(dep.port);
488
- if (serviceName) {
489
- dep.matchedService = serviceName;
490
- if (!dependsOn.includes(serviceName)) {
491
- dependsOn.push(serviceName);
492
- }
493
- }
494
- }
495
- // Update the main frontend service with depends_on
496
- // The main service is typically the one at root with port 3000/5173
497
- return services.map(service => {
498
- // If this service is at root and has detected proxy dependencies
499
- if ((service.root === '.' || service.root === './') && dependsOn.length > 0) {
500
- return {
501
- ...service,
502
- suggestedDependsOn: dependsOn,
503
- };
504
- }
505
- return service;
506
- });
507
- }