@oalacea/demon 1.0.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.
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Demon - Project Detector
3
+ *
4
+ * Analyzes a project directory to detect:
5
+ * - Framework (Next.js, Remix, SvelteKit, Vite, etc.)
6
+ * - Language (TypeScript, JavaScript, Python, etc.)
7
+ * - Test Runner (Vitest, Jest, Pytest, etc.)
8
+ * - Database (Prisma, Drizzle, Neon, Supabase, local)
9
+ * - Existing tests
10
+ * - Coverage
11
+ * - Key dependencies
12
+ * - Target URL
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { execSync } = require('child_process');
18
+
19
+ // Framework patterns
20
+ const FRAMEWORK_PATTERNS = {
21
+ 'Next.js': [
22
+ { file: 'package.json', pattern: /"next"\s*:/ },
23
+ { file: 'next.config.js', exists: true },
24
+ { file: 'next.config.mjs', exists: true },
25
+ { file: 'next.config.ts', exists: true },
26
+ ],
27
+ 'Remix': [
28
+ { file: 'package.json', pattern: /"@remix-run\/node"\s*:/ },
29
+ { file: 'remix.config.js', exists: true },
30
+ { file: 'remix.config.ts', exists: true },
31
+ ],
32
+ 'SvelteKit': [
33
+ { file: 'package.json', pattern: /"@sveltejs\/kit"\s*:/ },
34
+ { file: 'svelte.config.js', exists: true },
35
+ ],
36
+ 'Nuxt': [
37
+ { file: 'package.json', pattern: /"nuxt"\s*:/ },
38
+ { file: 'nuxt.config.ts', exists: true },
39
+ { file: 'nuxt.config.js', exists: true },
40
+ ],
41
+ 'Vite + React': [
42
+ { file: 'package.json', pattern: /"vite"\s*:/ },
43
+ { file: 'package.json', pattern: /"react"\s*:/ },
44
+ { not: ['Next.js', 'Remix', 'SvelteKit', 'Nuxt'] },
45
+ ],
46
+ 'Vite + Vue': [
47
+ { file: 'package.json', pattern: /"vite"\s*:/ },
48
+ { file: 'package.json', pattern: /"vue"\s*:/ },
49
+ ],
50
+ 'Vite + Svelte': [
51
+ { file: 'package.json', pattern: /"vite"\s*:/ },
52
+ { file: 'package.json', pattern: /"svelte"\s*:/ },
53
+ ],
54
+ 'Astro': [
55
+ { file: 'package.json', pattern: /"astro"\s*:/ },
56
+ ],
57
+ 'Gatsby': [
58
+ { file: 'package.json', pattern: /"gatsby"\s*:/ },
59
+ ],
60
+ 'Angular': [
61
+ { file: 'angular.json', exists: true },
62
+ ],
63
+ };
64
+
65
+ // Database patterns
66
+ const DATABASE_PATTERNS = {
67
+ 'Prisma Postgres': {
68
+ package: /@prisma\/client/,
69
+ schema: /provider\s*=\s*"postgresql"/,
70
+ },
71
+ 'Prisma MySQL': {
72
+ package: /@prisma\/client/,
73
+ schema: /provider\s*=\s*"mysql"/,
74
+ },
75
+ 'Prisma SQLite': {
76
+ package: /@prisma\/client/,
77
+ schema: /provider\s*=\s*"sqlite"/,
78
+ },
79
+ 'Prisma': {
80
+ package: /@prisma\/client/,
81
+ },
82
+ 'Drizzle': {
83
+ package: /drizzle-orm/,
84
+ },
85
+ 'TypeORM': {
86
+ package: /typeorm/,
87
+ },
88
+ 'MikroORM': {
89
+ package: /@mikro-orm/,
90
+ },
91
+ 'Mongoose': {
92
+ package: /mongoose/,
93
+ },
94
+ };
95
+
96
+ // Test runner patterns
97
+ const TEST_RUNNER_PATTERNS = {
98
+ 'Vitest': {
99
+ package: /vitest/,
100
+ config: ['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'],
101
+ },
102
+ 'Jest': {
103
+ package: /jest/,
104
+ config: ['jest.config.js', 'jest.config.ts', 'jest.config.mjs'],
105
+ },
106
+ 'Mocha': {
107
+ package: /mocha/,
108
+ },
109
+ 'Jasmine': {
110
+ package: /jasmine/,
111
+ },
112
+ };
113
+
114
+ // Important dependency categories
115
+ const DEPS_CATEGORIES = {
116
+ 'Router': ['@tanstack/react-router', '@tanstack/react-router', 'react-router', 'react-router-dom', 'next/router', '@remix-run/react', '@sveltejs/kit', 'vue-router', 'react-navigation'],
117
+ 'State': ['zustand', '@reduxjs/toolkit', 'redux', 'jotai', 'recoil', 'valtio', 'mobx', 'pinia', 'vuex'],
118
+ 'Query': ['@tanstack/react-query', '@tanstack/react-query', '@tanstack/solid-query', '@tanstack/vue-query', 'swr', 'react-query', '@apollo/client'],
119
+ 'Forms': ['react-hook-form', 'formik', 'zod', 'yup', 'joi', 'superstruct', 'valibot'],
120
+ 'UI': ['@radix-ui', '@headlessui', '@chakra-ui', '@mui/material', 'antd', 'mantine', 'shadcn/ui', 'tailwindcss'],
121
+ 'Testing': ['@testing-library/react', '@testing-library/vue', '@testing-library/svelte', '@testing-library/dom'],
122
+ 'E2E': ['@playwright/test', 'cypress', '@wdio/cli', 'nightwatch', 'testcafe'],
123
+ };
124
+
125
+ /**
126
+ * Detect database connection details from .env files
127
+ */
128
+ function detectDatabaseConnection(projectDir, dbType) {
129
+ const envFiles = ['.env', '.env.local', '.env.development', '.env.test'];
130
+ let connection = 'DATABASE_URL';
131
+ let provider = null;
132
+
133
+ for (const envFile of envFiles) {
134
+ const envPath = path.join(projectDir, envFile);
135
+ if (!fs.existsSync(envPath)) continue;
136
+
137
+ try {
138
+ const envContent = fs.readFileSync(envPath, 'utf-8');
139
+
140
+ // Detect provider-specific URLs
141
+ if (envContent.includes('neon.tech') || envContent.includes('NEON')) {
142
+ provider = 'Neon Postgres';
143
+ connection = 'DATABASE_URL (Neon)';
144
+ } else if (envContent.includes('supabase.co') || envContent.includes('SUPABASE')) {
145
+ provider = 'Supabase Postgres';
146
+ connection = 'DATABASE_URL (Supabase)';
147
+ } else if (envContent.includes('planetscale.com') || envContent.includes('PLANETSCALE')) {
148
+ provider = 'PlanetScale MySQL';
149
+ connection = 'DATABASE_URL (PlanetScale)';
150
+ } else if (envContent.includes('turso') || envContent.includes('TURSO')) {
151
+ provider = 'Turso SQLite';
152
+ connection = 'DATABASE_URL (Turso)';
153
+ } else if (envContent.includes('localhost') || envContent.includes('127.0.0.1')) {
154
+ provider = 'Local Database';
155
+ connection = 'DATABASE_URL (localhost)';
156
+ } else if (envContent.includes('railway.app') || envContent.includes('RAILWAY')) {
157
+ provider = 'Railway';
158
+ connection = 'DATABASE_URL (Railway)';
159
+ } else if (envContent.includes('render.com') || envContent.includes('RENDER')) {
160
+ provider = 'Render';
161
+ connection = 'DATABASE_URL (Render)';
162
+ }
163
+
164
+ // Extract port if local
165
+ if (provider === 'Local Database') {
166
+ const portMatch = envContent.match(/:(\d{4,5})/);
167
+ if (portMatch) {
168
+ connection = `localhost:${portMatch[1]}`;
169
+ }
170
+ }
171
+
172
+ if (provider) break;
173
+ } catch (e) {
174
+ // Skip files that can't be read
175
+ }
176
+ }
177
+
178
+ return {
179
+ type: provider || dbType || 'Database detected',
180
+ connection: connection,
181
+ testStrategy: 'transaction-rollback',
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Read package.json safely
187
+ */
188
+ function readPackageJson(projectDir) {
189
+ const pkgPath = path.join(projectDir, 'package.json');
190
+ if (!fs.existsSync(pkgPath)) {
191
+ return null;
192
+ }
193
+
194
+ try {
195
+ return JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
196
+ } catch (e) {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Detect framework from package.json and project structure
203
+ */
204
+ function detectFramework(projectDir, pkg) {
205
+ if (!pkg) return null;
206
+
207
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
208
+
209
+ for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
210
+ let matchScore = 0;
211
+
212
+ for (const pattern of patterns) {
213
+ if (pattern.exists !== undefined) {
214
+ const filePath = path.join(projectDir, pattern.file);
215
+ if (fs.existsSync(filePath) === pattern.exists) {
216
+ matchScore++;
217
+ }
218
+ } else if (pattern.pattern) {
219
+ if (pattern.pattern.test(JSON.stringify(allDeps))) {
220
+ matchScore++;
221
+ }
222
+ }
223
+ }
224
+
225
+ if (matchScore > 0) {
226
+ // Check if it should be excluded
227
+ if (patterns.some(p => p.not && p.not.includes(framework))) {
228
+ continue;
229
+ }
230
+ return framework;
231
+ }
232
+ }
233
+
234
+ // Fallback detection
235
+ if (allDeps.express) return 'Express';
236
+ if (allDeps.fastify) return 'Fastify';
237
+ if (allDeps.hono) return 'Hono';
238
+ if (allDeps.koa) return 'Koa';
239
+ if (allDeps.nest) return 'NestJS';
240
+ if (allDeps['@nestjs/core']) return 'NestJS';
241
+ if (allDeps.django || allDeps['django-rest-framework']) return 'Django';
242
+ if (allDeps.flask) return 'Flask';
243
+ if (allDeps.fastapi) return 'FastAPI';
244
+ if (allDeps.spring) return 'Spring Boot';
245
+
246
+ return null;
247
+ }
248
+
249
+ /**
250
+ * Detect language
251
+ */
252
+ function detectLanguage(projectDir, pkg) {
253
+ if (!pkg) return 'JavaScript';
254
+
255
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
256
+
257
+ // Check for TypeScript
258
+ if (allDeps.typescript || allDeps['@types/node']) {
259
+ return 'TypeScript';
260
+ }
261
+
262
+ // Check source files
263
+ const srcDir = path.join(projectDir, 'src');
264
+ if (fs.existsSync(srcDir)) {
265
+ const hasTs = hasFilesWithExtension(srcDir, ['.ts', '.tsx']);
266
+ const hasJs = hasFilesWithExtension(srcDir, ['.js', '.jsx']);
267
+
268
+ if (hasTs && !hasJs) return 'TypeScript';
269
+ if (hasTs && hasJs) return 'TypeScript + JavaScript';
270
+ }
271
+
272
+ return 'JavaScript';
273
+ }
274
+
275
+ /**
276
+ * Detect test runner
277
+ */
278
+ function detectTestRunner(projectDir, pkg) {
279
+ if (!pkg) return 'Vitest';
280
+
281
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
282
+
283
+ // Check for Vitest first (most common in modern projects)
284
+ if (allDeps.vitest) return 'Vitest';
285
+ if (allDeps.jest) return 'Jest';
286
+ if (allDeps.mocha) return 'Mocha';
287
+ if (allDeps.jasmine) return 'Jasmine';
288
+
289
+ // Check for config files
290
+ if (fs.existsSync(path.join(projectDir, 'vitest.config.ts'))) return 'Vitest';
291
+ if (fs.existsSync(path.join(projectDir, 'vitest.config.js'))) return 'Vitest';
292
+ if (fs.existsSync(path.join(projectDir, 'jest.config.js'))) return 'Jest';
293
+ if (fs.existsSync(path.join(projectDir, 'jest.config.ts'))) return 'Jest';
294
+
295
+ return 'Vitest'; // Default for modern projects
296
+ }
297
+
298
+ /**
299
+ * Detect database
300
+ */
301
+ function detectDatabase(projectDir, pkg) {
302
+ if (!pkg) return null;
303
+
304
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
305
+
306
+ // Check for Prisma
307
+ if (allDeps['@prisma/client'] || allDeps['@prisma/client']) {
308
+ const schemaPath = path.join(projectDir, 'prisma', 'schema.prisma');
309
+ let provider = 'Prisma';
310
+
311
+ if (fs.existsSync(schemaPath)) {
312
+ try {
313
+ const schema = fs.readFileSync(schemaPath, 'utf-8');
314
+ if (schema.includes('provider = "postgresql"')) {
315
+ provider = 'Prisma Postgres';
316
+ } else if (schema.includes('provider = "mysql"')) {
317
+ provider = 'Prisma MySQL';
318
+ } else if (schema.includes('provider = "sqlite"')) {
319
+ provider = 'Prisma SQLite';
320
+ } else if (schema.includes('provider = "mongodb"')) {
321
+ provider = 'Prisma MongoDB';
322
+ } else if (schema.includes('provider = "cockroachdb"')) {
323
+ provider = 'Prisma CockroachDB';
324
+ }
325
+ } catch (e) {
326
+ // Schema not readable
327
+ }
328
+ }
329
+
330
+ return detectDatabaseConnection(projectDir, provider);
331
+ }
332
+
333
+ // Check for Drizzle
334
+ if (allDeps['drizzle-orm']) {
335
+ return detectDatabaseConnection(projectDir, 'Drizzle ORM');
336
+ }
337
+
338
+ // Check for TypeORM
339
+ if (allDeps.typeorm) {
340
+ return detectDatabaseConnection(projectDir, 'TypeORM');
341
+ }
342
+
343
+ // Check for MikroORM
344
+ if (allDeps['@mikro-orm'] || allDeps['@mikro-orm/core']) {
345
+ return detectDatabaseConnection(projectDir, 'MikroORM');
346
+ }
347
+
348
+ // Check for Mongoose
349
+ if (allDeps.mongoose) {
350
+ return detectDatabaseConnection(projectDir, 'MongoDB (Mongoose)');
351
+ }
352
+
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * Count existing test files
358
+ */
359
+ function countExistingTests(projectDir) {
360
+ const testExtensions = ['.test.ts', '.test.tsx', '.test.js', '.test.jsx',
361
+ '.spec.ts', '.spec.tsx', '.spec.js', '.spec.jsx'];
362
+
363
+ let count = 0;
364
+
365
+ function countInDir(dir) {
366
+ try {
367
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
368
+
369
+ for (const entry of entries) {
370
+ const fullPath = path.join(dir, entry.name);
371
+
372
+ if (entry.isDirectory()) {
373
+ // Skip node_modules and other common exclusions
374
+ if (['node_modules', '.next', 'dist', 'build', '.git', 'coverage'].includes(entry.name)) {
375
+ continue;
376
+ }
377
+ countInDir(fullPath);
378
+ } else if (entry.isFile()) {
379
+ const ext = path.extname(entry.name);
380
+ const baseName = path.basename(entry.name, ext);
381
+ const fullName = baseName + ext;
382
+
383
+ if (testExtensions.some(te => fullName.endsWith(te))) {
384
+ count++;
385
+ }
386
+ }
387
+ }
388
+ } catch (e) {
389
+ // Skip directories that can't be read
390
+ }
391
+ }
392
+
393
+ countInDir(projectDir);
394
+ return count;
395
+ }
396
+
397
+ /**
398
+ * Get coverage if available
399
+ */
400
+ function getCoverage(projectDir) {
401
+ const coverageDirs = ['coverage', '.nyc_output'];
402
+ const coverageFiles = ['coverage/coverage-summary.json', 'coverage-summary.json'];
403
+
404
+ for (const file of coverageFiles) {
405
+ const filePath = path.join(projectDir, file);
406
+ if (fs.existsSync(filePath)) {
407
+ try {
408
+ const coverage = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
409
+ // Try to extract total coverage
410
+ if (coverage.total) {
411
+ const lines = coverage.total.lines?.pct || 0;
412
+ return `${Math.round(lines)}%`;
413
+ }
414
+ } catch (e) {
415
+ // Can't read coverage
416
+ }
417
+ }
418
+ }
419
+
420
+ return null;
421
+ }
422
+
423
+ /**
424
+ * Get key dependencies by category
425
+ */
426
+ function getKeyDependencies(projectDir, pkg) {
427
+ if (!pkg) return [];
428
+
429
+ const allDeps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
430
+ const keyDeps = [];
431
+
432
+ for (const [category, patterns] of Object.entries(DEPS_CATEGORIES)) {
433
+ for (const pattern of patterns) {
434
+ if (allDeps[pattern]) {
435
+ keyDeps.push(`${category}: ${pattern}`);
436
+ break;
437
+ }
438
+ }
439
+ }
440
+
441
+ // Add some important standalone dependencies
442
+ if (allDeps.zod && !keyDeps.some(d => d.includes('zod'))) {
443
+ keyDeps.push('Forms: zod');
444
+ }
445
+ if (allDeps.next) {
446
+ keyDeps.push('Framework: next');
447
+ }
448
+ if (allDeps.react) {
449
+ keyDeps.push('UI: react');
450
+ }
451
+ if (allDeps.vue) {
452
+ keyDeps.push('UI: vue');
453
+ }
454
+ if (allDeps.svelte) {
455
+ keyDeps.push('UI: svelte');
456
+ }
457
+
458
+ return keyDeps;
459
+ }
460
+
461
+ /**
462
+ * Detect target URL for testing
463
+ */
464
+ function detectTargetUrl(projectDir, pkg) {
465
+ const platform = process.platform;
466
+
467
+ // Determine host based on platform
468
+ let host;
469
+ if (platform === 'linux') {
470
+ host = 'localhost';
471
+ } else {
472
+ // macOS and Windows Docker runs in a VM
473
+ host = 'host.docker.internal';
474
+ }
475
+
476
+ // Try to detect port from package.json scripts
477
+ let port = '3000'; // Default
478
+
479
+ if (pkg && pkg.scripts) {
480
+ const scripts = Object.values(pkg.scripts).join(' ');
481
+
482
+ const portMatches = scripts.match(/-p\s*(\d{4,5})/gi);
483
+ if (portMatches) {
484
+ const numbers = portMatches.map(m => parseInt(m.replace(/-p\s*/, '')));
485
+ port = numbers[0]?.toString() || port;
486
+ }
487
+
488
+ const devPortMatch = scripts.match(/PORT=(\d{4,5})/i);
489
+ if (devPortMatch) {
490
+ port = devPortMatch[1];
491
+ }
492
+
493
+ const nextPortMatch = scripts.match(/next\s+dev.*-p\s*(\d{4,5})/);
494
+ if (nextPortMatch) {
495
+ port = nextPortMatch[1];
496
+ }
497
+ }
498
+
499
+ // Check .env files for PORT
500
+ const envFiles = ['.env', '.env.local', '.env.development'];
501
+ for (const envFile of envFiles) {
502
+ const envPath = path.join(projectDir, envFile);
503
+ if (fs.existsSync(envPath)) {
504
+ try {
505
+ const envContent = fs.readFileSync(envPath, 'utf-8');
506
+ const portMatch = envContent.match(/^PORT=(\d{4,5})/m);
507
+ if (portMatch) {
508
+ port = portMatch[1];
509
+ break;
510
+ }
511
+ } catch (e) {
512
+ // Skip
513
+ }
514
+ }
515
+ }
516
+
517
+ return `http://${host}:${port}`;
518
+ }
519
+
520
+ /**
521
+ * Helper: Check if directory has files with extension
522
+ */
523
+ function hasFilesWithExtension(dir, extensions) {
524
+ try {
525
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
526
+ for (const entry of entries) {
527
+ if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
528
+ return true;
529
+ }
530
+ }
531
+ } catch (e) {
532
+ // Directory not readable
533
+ }
534
+ return false;
535
+ }
536
+
537
+ /**
538
+ * Main analysis function
539
+ */
540
+ async function analyze(projectDir) {
541
+ const pkg = readPackageJson(projectDir);
542
+
543
+ const context = {
544
+ framework: detectFramework(projectDir, pkg) || 'Unknown',
545
+ language: detectLanguage(projectDir, pkg),
546
+ testRunner: detectTestRunner(projectDir, pkg),
547
+ database: detectDatabase(projectDir, pkg),
548
+ existingTests: countExistingTests(projectDir),
549
+ coverage: getCoverage(projectDir),
550
+ dependencies: getKeyDependencies(projectDir, pkg),
551
+ target: detectTargetUrl(projectDir, pkg),
552
+ };
553
+
554
+ return context;
555
+ }
556
+
557
+ // Export for use in CLI
558
+ if (require.main === module) {
559
+ const projectDir = process.argv[2] || process.cwd();
560
+ analyze(projectDir)
561
+ .then(context => {
562
+ console.log(JSON.stringify(context, null, 2));
563
+ })
564
+ .catch(err => {
565
+ console.error('Analysis failed:', err);
566
+ process.exit(1);
567
+ });
568
+ }
569
+
570
+ module.exports = { analyze };