@gurulu/cli 0.3.4 → 0.4.1
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 +61 -24
- package/dist/api-client.js +1 -1
- package/dist/commands/add-server.js +13 -6
- package/dist/commands/alerts.d.ts +5 -0
- package/dist/commands/alerts.js +43 -15
- package/dist/commands/audiences.d.ts +3 -0
- package/dist/commands/audiences.js +34 -7
- package/dist/commands/events.d.ts +6 -0
- package/dist/commands/events.js +182 -1
- package/dist/commands/experiments.d.ts +4 -0
- package/dist/commands/experiments.js +46 -15
- package/dist/commands/funnels.d.ts +17 -0
- package/dist/commands/funnels.js +203 -0
- package/dist/commands/goals.d.ts +18 -0
- package/dist/commands/goals.js +214 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +74 -4
- package/dist/commands/sourcemap.d.ts +17 -5
- package/dist/commands/sourcemap.js +73 -6
- package/dist/commands/watch.d.ts +45 -0
- package/dist/commands/watch.js +258 -0
- package/dist/frameworks/detect.js +29 -7
- package/dist/index.js +158 -13
- package/package.json +1 -1
- package/scripts/gurulu-agentic-install.mjs +225 -0
- package/scripts/gurulu-scan.lib.cjs +539 -19
- package/scripts/patches/astro.patch.cjs +1 -0
- package/scripts/patches/auto-instrument/hono.cjs +381 -0
- package/scripts/patches/auto-instrument/index.cjs +2 -0
- package/scripts/patches/auto-instrument/nextjs-app-router.cjs +13 -4
- package/scripts/patches/express.patch.cjs +2 -2
- package/scripts/patches/fastify.patch.cjs +1 -0
- package/scripts/patches/nestjs.patch.cjs +1 -0
- package/scripts/patches/nextjs-app-router.patch.cjs +2 -2
- package/scripts/patches/nextjs-pages.patch.cjs +1 -0
- package/scripts/patches/remix.patch.cjs +1 -0
- package/scripts/patches/sveltekit.patch.cjs +1 -0
- package/scripts/patches/vite-react.patch.cjs +1 -0
- package/scripts/patches/vue.patch.cjs +1 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
10
10
|
const fs = require('node:fs').promises;
|
|
11
|
+
const fsSync = require('node:fs');
|
|
11
12
|
const path = require('node:path');
|
|
12
13
|
|
|
13
14
|
const SCAN_VERSION = '2';
|
|
@@ -22,24 +23,79 @@ const SKIP_DIRS = new Set([
|
|
|
22
23
|
'.turbo',
|
|
23
24
|
'.vercel',
|
|
24
25
|
'coverage',
|
|
26
|
+
// Mobile
|
|
27
|
+
'Pods', // iOS CocoaPods
|
|
28
|
+
'.gradle', // Android Gradle cache
|
|
29
|
+
'.dart_tool', // Flutter
|
|
30
|
+
'.pub-cache', // Flutter pub cache
|
|
25
31
|
]);
|
|
26
32
|
|
|
27
33
|
const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
28
34
|
|
|
35
|
+
// Mobile code extensions for screen/route scanning
|
|
36
|
+
const MOBILE_CODE_EXTS = new Set([
|
|
37
|
+
'.ts', '.tsx', '.js', '.jsx', // React Native
|
|
38
|
+
'.swift', // iOS
|
|
39
|
+
'.kt', '.java', // Android
|
|
40
|
+
'.dart', // Flutter
|
|
41
|
+
]);
|
|
42
|
+
|
|
29
43
|
// ---------------------------------------------------------------------------
|
|
30
44
|
// Framework detection
|
|
31
45
|
// ---------------------------------------------------------------------------
|
|
32
|
-
function detectFramework(deps = {}) {
|
|
46
|
+
function detectFramework(deps = {}, rootPath) {
|
|
33
47
|
const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
34
48
|
const ver = (name) =>
|
|
35
49
|
deps[name] ? String(deps[name]).replace(/^[^\d]*/, '') || null : null;
|
|
36
50
|
|
|
51
|
+
// --- Mobile framework detection (must come before web frameworks) ---
|
|
52
|
+
|
|
53
|
+
// React Native: has react-native in deps — check BEFORE other React-based frameworks
|
|
54
|
+
if (has('react-native')) {
|
|
55
|
+
return { name: 'react-native', version: ver('react-native') };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (rootPath) {
|
|
59
|
+
// Flutter: check for pubspec.yaml
|
|
60
|
+
if (fsSync.existsSync(path.join(rootPath, 'pubspec.yaml'))) {
|
|
61
|
+
return { name: 'flutter', version: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// iOS: check for .xcodeproj, .xcworkspace, or Package.swift
|
|
65
|
+
try {
|
|
66
|
+
const entries = fsSync.readdirSync(rootPath);
|
|
67
|
+
const hasXcode = entries.some(
|
|
68
|
+
(e) => e.endsWith('.xcodeproj') || e.endsWith('.xcworkspace'),
|
|
69
|
+
);
|
|
70
|
+
const hasPackageSwift = fsSync.existsSync(path.join(rootPath, 'Package.swift'));
|
|
71
|
+
if (hasXcode || hasPackageSwift) {
|
|
72
|
+
return { name: 'ios-swift', version: null };
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
/* skip */
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Android: check for build.gradle / build.gradle.kts (only when no package.json deps,
|
|
79
|
+
// otherwise it could be a monorepo with an android/ subfolder)
|
|
80
|
+
if (Object.keys(deps).length === 0) {
|
|
81
|
+
if (
|
|
82
|
+
fsSync.existsSync(path.join(rootPath, 'build.gradle')) ||
|
|
83
|
+
fsSync.existsSync(path.join(rootPath, 'build.gradle.kts')) ||
|
|
84
|
+
fsSync.existsSync(path.join(rootPath, 'app', 'build.gradle'))
|
|
85
|
+
) {
|
|
86
|
+
return { name: 'android-kotlin', version: null };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// --- Web framework detection ---
|
|
37
92
|
if (has('next')) return { name: 'nextjs', version: ver('next') };
|
|
38
93
|
if (has('@nestjs/core')) return { name: 'nestjs', version: ver('@nestjs/core') };
|
|
39
94
|
if (has('@remix-run/dev')) return { name: 'remix', version: ver('@remix-run/dev') };
|
|
40
95
|
if (has('@sveltejs/kit')) return { name: 'sveltekit', version: ver('@sveltejs/kit') };
|
|
41
96
|
if (has('astro')) return { name: 'astro', version: ver('astro') };
|
|
42
97
|
if (has('fastify')) return { name: 'fastify', version: ver('fastify') };
|
|
98
|
+
if (has('hono')) return { name: 'hono', version: ver('hono') };
|
|
43
99
|
if (has('express')) return { name: 'express', version: ver('express') };
|
|
44
100
|
if (has('vite') && has('react')) return { name: 'vite-react', version: ver('vite') };
|
|
45
101
|
if (has('vue')) return { name: 'vue', version: ver('vue') };
|
|
@@ -52,6 +108,13 @@ function detectFramework(deps = {}) {
|
|
|
52
108
|
function detectORM(deps = {}, schemaHints = {}) {
|
|
53
109
|
const has = (name) => Object.prototype.hasOwnProperty.call(deps, name);
|
|
54
110
|
|
|
111
|
+
// Mobile ORM / persistence (package.json-based, e.g. React Native)
|
|
112
|
+
if (has('realm') || has('@realm/react')) return { name: 'realm', schemaPath: null };
|
|
113
|
+
if (has('react-native-sqlite-storage') || has('react-native-sqlite-2') || has('expo-sqlite')) {
|
|
114
|
+
return { name: 'sqlite', schemaPath: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Web ORMs
|
|
55
118
|
if (has('@prisma/client') || has('prisma')) {
|
|
56
119
|
return { name: 'prisma', schemaPath: schemaHints.prisma || null };
|
|
57
120
|
}
|
|
@@ -65,6 +128,63 @@ function detectORM(deps = {}, schemaHints = {}) {
|
|
|
65
128
|
return { name: 'unknown', schemaPath: null };
|
|
66
129
|
}
|
|
67
130
|
|
|
131
|
+
/**
|
|
132
|
+
* detectMobileORM — filesystem-based ORM detection for mobile projects
|
|
133
|
+
* that don't use package.json (iOS/Android/Flutter).
|
|
134
|
+
*/
|
|
135
|
+
function detectMobileORM(rootPath, framework) {
|
|
136
|
+
if (!framework || !rootPath) return { name: 'unknown', schemaPath: null };
|
|
137
|
+
|
|
138
|
+
if (framework.name === 'ios-swift') {
|
|
139
|
+
// Core Data: look for .xcdatamodeld directories
|
|
140
|
+
try {
|
|
141
|
+
const entries = fsSync.readdirSync(rootPath);
|
|
142
|
+
if (entries.some((e) => e.endsWith('.xcdatamodeld'))) {
|
|
143
|
+
return { name: 'coredata', schemaPath: null };
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
/* skip */
|
|
147
|
+
}
|
|
148
|
+
// Realm for iOS (check via SPM Package.swift or Podfile)
|
|
149
|
+
for (const f of ['Package.swift', 'Podfile']) {
|
|
150
|
+
try {
|
|
151
|
+
const content = fsSync.readFileSync(path.join(rootPath, f), 'utf8');
|
|
152
|
+
if (/realm/i.test(content)) return { name: 'realm', schemaPath: null };
|
|
153
|
+
} catch {
|
|
154
|
+
/* skip */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (framework.name === 'android-kotlin') {
|
|
160
|
+
// Room: check build.gradle for androidx.room dependency
|
|
161
|
+
for (const gf of ['build.gradle', 'build.gradle.kts', 'app/build.gradle', 'app/build.gradle.kts']) {
|
|
162
|
+
try {
|
|
163
|
+
const content = fsSync.readFileSync(path.join(rootPath, gf), 'utf8');
|
|
164
|
+
if (/androidx\.room/.test(content)) return { name: 'room', schemaPath: null };
|
|
165
|
+
if (/realm/i.test(content)) return { name: 'realm', schemaPath: null };
|
|
166
|
+
} catch {
|
|
167
|
+
/* skip */
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (framework.name === 'flutter') {
|
|
173
|
+
// sqflite / drift / floor — check pubspec.yaml
|
|
174
|
+
try {
|
|
175
|
+
const pubspec = fsSync.readFileSync(path.join(rootPath, 'pubspec.yaml'), 'utf8');
|
|
176
|
+
if (/\bsqflite\b/.test(pubspec)) return { name: 'sqlite', schemaPath: null };
|
|
177
|
+
if (/\bdrift\b/.test(pubspec)) return { name: 'drift', schemaPath: null };
|
|
178
|
+
if (/\bfloor\b/.test(pubspec)) return { name: 'floor', schemaPath: null };
|
|
179
|
+
if (/\brealm\b/.test(pubspec)) return { name: 'realm', schemaPath: null };
|
|
180
|
+
} catch {
|
|
181
|
+
/* skip */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { name: 'unknown', schemaPath: null };
|
|
186
|
+
}
|
|
187
|
+
|
|
68
188
|
// ---------------------------------------------------------------------------
|
|
69
189
|
// Auth detection
|
|
70
190
|
// ---------------------------------------------------------------------------
|
|
@@ -113,6 +233,308 @@ async function walk(dir, collected = []) {
|
|
|
113
233
|
return collected;
|
|
114
234
|
}
|
|
115
235
|
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Route detection — Express
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
const EXPRESS_ROUTE_PATTERN =
|
|
240
|
+
/(?:app|router)\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
241
|
+
const EXPRESS_USE_PATTERN =
|
|
242
|
+
/(?:app|router)\.use\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
243
|
+
|
|
244
|
+
async function scanExpressRoutes(rootPath) {
|
|
245
|
+
const ROUTE_LIMIT = 100;
|
|
246
|
+
const candidateDirs = ['src/routes', 'routes', 'src/controllers', 'src/api', 'app'];
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
const routes = [];
|
|
249
|
+
|
|
250
|
+
// Collect prefix hints from app.use('/prefix', router)
|
|
251
|
+
const prefixes = [];
|
|
252
|
+
|
|
253
|
+
for (const base of candidateDirs) {
|
|
254
|
+
const dir = path.join(rootPath, base);
|
|
255
|
+
let files;
|
|
256
|
+
try {
|
|
257
|
+
files = await walk(dir);
|
|
258
|
+
} catch {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
for (const abs of files) {
|
|
262
|
+
if (!CODE_EXTS.has(path.extname(abs))) continue;
|
|
263
|
+
let content;
|
|
264
|
+
try {
|
|
265
|
+
content = await fs.readFile(abs, 'utf8');
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (content.length > 1_500_000) continue;
|
|
270
|
+
|
|
271
|
+
// Collect use() prefixes
|
|
272
|
+
const useRe = new RegExp(EXPRESS_USE_PATTERN.source, EXPRESS_USE_PATTERN.flags);
|
|
273
|
+
let um;
|
|
274
|
+
while ((um = useRe.exec(content)) !== null) {
|
|
275
|
+
const prefix = um[1].replace(/\/+$/, '');
|
|
276
|
+
if (prefix && prefix !== '/') prefixes.push(prefix);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Collect routes
|
|
280
|
+
const re = new RegExp(EXPRESS_ROUTE_PATTERN.source, EXPRESS_ROUTE_PATTERN.flags);
|
|
281
|
+
let m;
|
|
282
|
+
while ((m = re.exec(content)) !== null) {
|
|
283
|
+
const method = m[1].toUpperCase();
|
|
284
|
+
const routePath = m[2];
|
|
285
|
+
const key = `${method}:${routePath}`;
|
|
286
|
+
if (seen.has(key)) continue;
|
|
287
|
+
seen.add(key);
|
|
288
|
+
routes.push({ method, path: routePath });
|
|
289
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Also scan entry files at root and src/ level
|
|
295
|
+
for (const baseDir of ['', 'src']) {
|
|
296
|
+
for (const entry of ['index', 'server', 'app', 'main']) {
|
|
297
|
+
for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
|
|
298
|
+
const abs = path.join(rootPath, baseDir, `${entry}${ext}`);
|
|
299
|
+
let content;
|
|
300
|
+
try {
|
|
301
|
+
content = await fs.readFile(abs, 'utf8');
|
|
302
|
+
} catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (content.length > 1_500_000) continue;
|
|
306
|
+
|
|
307
|
+
const useRe = new RegExp(EXPRESS_USE_PATTERN.source, EXPRESS_USE_PATTERN.flags);
|
|
308
|
+
let um;
|
|
309
|
+
while ((um = useRe.exec(content)) !== null) {
|
|
310
|
+
const prefix = um[1].replace(/\/+$/, '');
|
|
311
|
+
if (prefix && prefix !== '/') prefixes.push(prefix);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const re = new RegExp(EXPRESS_ROUTE_PATTERN.source, EXPRESS_ROUTE_PATTERN.flags);
|
|
315
|
+
let m;
|
|
316
|
+
while ((m = re.exec(content)) !== null) {
|
|
317
|
+
const method = m[1].toUpperCase();
|
|
318
|
+
const routePath = m[2];
|
|
319
|
+
const key = `${method}:${routePath}`;
|
|
320
|
+
if (seen.has(key)) continue;
|
|
321
|
+
seen.add(key);
|
|
322
|
+
routes.push({ method, path: routePath });
|
|
323
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return { routes };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Route detection — Fastify
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
const FASTIFY_PATTERN =
|
|
336
|
+
/fastify\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
337
|
+
const FASTIFY_ROUTE_PATTERN =
|
|
338
|
+
/fastify\.route\s*\(\s*\{[^}]*method\s*:\s*['"`](\w+)['"`][^}]*url\s*:\s*['"`]([^'"`]+)['"`]/gi;
|
|
339
|
+
|
|
340
|
+
async function scanFastifyRoutes(rootPath) {
|
|
341
|
+
const ROUTE_LIMIT = 100;
|
|
342
|
+
const candidateDirs = ['src/routes', 'routes', 'src/controllers', 'src/api', 'app'];
|
|
343
|
+
const seen = new Set();
|
|
344
|
+
const routes = [];
|
|
345
|
+
|
|
346
|
+
const scanFile = async (abs) => {
|
|
347
|
+
if (!CODE_EXTS.has(path.extname(abs))) return;
|
|
348
|
+
let content;
|
|
349
|
+
try {
|
|
350
|
+
content = await fs.readFile(abs, 'utf8');
|
|
351
|
+
} catch {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (content.length > 1_500_000) return;
|
|
355
|
+
|
|
356
|
+
// Shorthand: fastify.get('/path', ...)
|
|
357
|
+
const re1 = new RegExp(FASTIFY_PATTERN.source, FASTIFY_PATTERN.flags);
|
|
358
|
+
let m;
|
|
359
|
+
while ((m = re1.exec(content)) !== null) {
|
|
360
|
+
const method = m[1].toUpperCase();
|
|
361
|
+
const routePath = m[2];
|
|
362
|
+
const key = `${method}:${routePath}`;
|
|
363
|
+
if (seen.has(key)) continue;
|
|
364
|
+
seen.add(key);
|
|
365
|
+
routes.push({ method, path: routePath });
|
|
366
|
+
if (routes.length >= ROUTE_LIMIT) return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// fastify.route({ method: 'GET', url: '/path' })
|
|
370
|
+
const re2 = new RegExp(FASTIFY_ROUTE_PATTERN.source, FASTIFY_ROUTE_PATTERN.flags);
|
|
371
|
+
while ((m = re2.exec(content)) !== null) {
|
|
372
|
+
const method = m[1].toUpperCase();
|
|
373
|
+
const routePath = m[2];
|
|
374
|
+
const key = `${method}:${routePath}`;
|
|
375
|
+
if (seen.has(key)) continue;
|
|
376
|
+
seen.add(key);
|
|
377
|
+
routes.push({ method, path: routePath });
|
|
378
|
+
if (routes.length >= ROUTE_LIMIT) return;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
for (const base of candidateDirs) {
|
|
383
|
+
const dir = path.join(rootPath, base);
|
|
384
|
+
let files;
|
|
385
|
+
try {
|
|
386
|
+
files = await walk(dir);
|
|
387
|
+
} catch {
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
for (const abs of files) {
|
|
391
|
+
await scanFile(abs);
|
|
392
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Scan entry files at root and src/ level
|
|
397
|
+
for (const baseDir of ['', 'src']) {
|
|
398
|
+
for (const entry of ['index', 'server', 'app', 'main']) {
|
|
399
|
+
for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
|
|
400
|
+
await scanFile(path.join(rootPath, baseDir, `${entry}${ext}`));
|
|
401
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { routes };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Route detection — Hono
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
const HONO_PATTERN =
|
|
413
|
+
/(?:app|api|router|server)\.(get|post|put|delete|patch|options|head)\s*\(\s*['"`]([^'"`]+)['"`]/gi;
|
|
414
|
+
|
|
415
|
+
async function scanHonoRoutes(rootPath) {
|
|
416
|
+
const ROUTE_LIMIT = 100;
|
|
417
|
+
const candidateDirs = ['src/routes', 'routes', 'src/api', 'app', 'src/app'];
|
|
418
|
+
const seen = new Set();
|
|
419
|
+
const routes = [];
|
|
420
|
+
|
|
421
|
+
const scanFile = async (abs) => {
|
|
422
|
+
if (!CODE_EXTS.has(path.extname(abs))) return;
|
|
423
|
+
let content;
|
|
424
|
+
try {
|
|
425
|
+
content = await fs.readFile(abs, 'utf8');
|
|
426
|
+
} catch {
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (content.length > 1_500_000) return;
|
|
430
|
+
// Must reference Hono somewhere in the file to avoid false positives
|
|
431
|
+
// from Express/Fastify files using the same `.get(...)` pattern.
|
|
432
|
+
if (!content.includes('Hono') && !content.includes('hono')) return;
|
|
433
|
+
|
|
434
|
+
const re1 = new RegExp(HONO_PATTERN.source, HONO_PATTERN.flags);
|
|
435
|
+
let m;
|
|
436
|
+
while ((m = re1.exec(content)) !== null) {
|
|
437
|
+
const method = m[1].toUpperCase();
|
|
438
|
+
const routePath = m[2];
|
|
439
|
+
const key = `${method}:${routePath}`;
|
|
440
|
+
if (seen.has(key)) continue;
|
|
441
|
+
seen.add(key);
|
|
442
|
+
routes.push({ method, path: routePath });
|
|
443
|
+
if (routes.length >= ROUTE_LIMIT) return;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
for (const base of candidateDirs) {
|
|
448
|
+
const dir = path.join(rootPath, base);
|
|
449
|
+
let files;
|
|
450
|
+
try {
|
|
451
|
+
files = await walk(dir);
|
|
452
|
+
} catch {
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
for (const abs of files) {
|
|
456
|
+
await scanFile(abs);
|
|
457
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Scan entry files at root and src/ level
|
|
462
|
+
for (const baseDir of ['', 'src']) {
|
|
463
|
+
for (const entry of ['index', 'server', 'app', 'main']) {
|
|
464
|
+
for (const ext of ['.ts', '.js', '.mjs', '.cjs']) {
|
|
465
|
+
await scanFile(path.join(rootPath, baseDir, `${entry}${ext}`));
|
|
466
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return { routes };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
// Route detection — NestJS
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
const NEST_DECORATOR =
|
|
478
|
+
/@(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*['"`]?([^'"`\)]*)/gi;
|
|
479
|
+
const NEST_CONTROLLER =
|
|
480
|
+
/@Controller\s*\(\s*['"`]([^'"`]+)/gi;
|
|
481
|
+
|
|
482
|
+
async function scanNestRoutes(rootPath) {
|
|
483
|
+
const ROUTE_LIMIT = 100;
|
|
484
|
+
const candidateDirs = ['src', 'src/modules', 'src/controllers', 'src/api', 'app'];
|
|
485
|
+
const seen = new Set();
|
|
486
|
+
const routes = [];
|
|
487
|
+
|
|
488
|
+
const scanFile = async (abs) => {
|
|
489
|
+
if (!CODE_EXTS.has(path.extname(abs))) return;
|
|
490
|
+
let content;
|
|
491
|
+
try {
|
|
492
|
+
content = await fs.readFile(abs, 'utf8');
|
|
493
|
+
} catch {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (content.length > 1_500_000) return;
|
|
497
|
+
|
|
498
|
+
// Find @Controller prefix
|
|
499
|
+
const controllerRe = new RegExp(NEST_CONTROLLER.source, NEST_CONTROLLER.flags);
|
|
500
|
+
const controllerMatch = controllerRe.exec(content);
|
|
501
|
+
const controllerPrefix = controllerMatch
|
|
502
|
+
? '/' + controllerMatch[1].replace(/^\/+|\/+$/g, '')
|
|
503
|
+
: '';
|
|
504
|
+
|
|
505
|
+
// Find route decorators
|
|
506
|
+
const decoratorRe = new RegExp(NEST_DECORATOR.source, NEST_DECORATOR.flags);
|
|
507
|
+
let m;
|
|
508
|
+
while ((m = decoratorRe.exec(content)) !== null) {
|
|
509
|
+
const method = m[1].toUpperCase();
|
|
510
|
+
const rawPath = (m[2] || '').trim().replace(/['"`]/g, '');
|
|
511
|
+
const suffix = rawPath ? '/' + rawPath.replace(/^\/+/, '') : '';
|
|
512
|
+
const routePath = (controllerPrefix + suffix) || '/';
|
|
513
|
+
const key = `${method}:${routePath}`;
|
|
514
|
+
if (seen.has(key)) continue;
|
|
515
|
+
seen.add(key);
|
|
516
|
+
routes.push({ method, path: routePath });
|
|
517
|
+
if (routes.length >= ROUTE_LIMIT) return;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
for (const base of candidateDirs) {
|
|
522
|
+
const dir = path.join(rootPath, base);
|
|
523
|
+
let files;
|
|
524
|
+
try {
|
|
525
|
+
files = await walk(dir);
|
|
526
|
+
} catch {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
for (const abs of files) {
|
|
530
|
+
await scanFile(abs);
|
|
531
|
+
if (routes.length >= ROUTE_LIMIT) return { routes };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return { routes };
|
|
536
|
+
}
|
|
537
|
+
|
|
116
538
|
// ---------------------------------------------------------------------------
|
|
117
539
|
// Route detection (Next.js App + Pages router)
|
|
118
540
|
// ---------------------------------------------------------------------------
|
|
@@ -139,17 +561,95 @@ function routePathFromPagesRouterFile(relFile) {
|
|
|
139
561
|
return '/' + segs.join('/');
|
|
140
562
|
}
|
|
141
563
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
// Mobile screen/route detection
|
|
566
|
+
// ---------------------------------------------------------------------------
|
|
567
|
+
|
|
568
|
+
// React Native: Stack.Screen, Tab.Screen, Drawer.Screen name patterns
|
|
569
|
+
const RN_SCREEN_RE = /(?:Stack|Tab|Drawer)\.Screen\s+name\s*=\s*['"`]([^'"`]+)['"`]/g;
|
|
570
|
+
// iOS: UIViewController subclasses
|
|
571
|
+
const IOS_VC_RE = /class\s+(\w+)\s*:\s*(?:UI)?ViewController/g;
|
|
572
|
+
// Android: Activity / Fragment classes
|
|
573
|
+
const ANDROID_ACTIVITY_RE = /class\s+(\w+)\s*:\s*(?:AppCompat)?Activity\b/g;
|
|
574
|
+
const ANDROID_FRAGMENT_RE = /class\s+(\w+)\s*:\s*(?:Fragment)\b/g;
|
|
575
|
+
// Flutter: StatelessWidget / StatefulWidget
|
|
576
|
+
const FLUTTER_WIDGET_RE = /class\s+(\w+)\s+extends\s+Stateful?Widget\b/g;
|
|
577
|
+
|
|
578
|
+
async function scanMobileScreens(rootPath, framework) {
|
|
579
|
+
const SCREEN_LIMIT = 100;
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
const routes = [];
|
|
582
|
+
|
|
583
|
+
let files;
|
|
584
|
+
try {
|
|
585
|
+
files = await walk(rootPath);
|
|
586
|
+
} catch {
|
|
587
|
+
return { routes: [] };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
for (const abs of files) {
|
|
591
|
+
const ext = path.extname(abs);
|
|
592
|
+
if (!MOBILE_CODE_EXTS.has(ext)) continue;
|
|
593
|
+
let content;
|
|
594
|
+
try {
|
|
595
|
+
content = await fs.readFile(abs, 'utf8');
|
|
596
|
+
} catch {
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (content.length > 1_500_000) continue;
|
|
600
|
+
|
|
601
|
+
const rel = path.relative(rootPath, abs);
|
|
602
|
+
let patterns = [];
|
|
603
|
+
|
|
604
|
+
if (framework.name === 'react-native') {
|
|
605
|
+
patterns = [RN_SCREEN_RE];
|
|
606
|
+
} else if (framework.name === 'ios-swift') {
|
|
607
|
+
patterns = [IOS_VC_RE];
|
|
608
|
+
} else if (framework.name === 'android-kotlin') {
|
|
609
|
+
patterns = [ANDROID_ACTIVITY_RE, ANDROID_FRAGMENT_RE];
|
|
610
|
+
} else if (framework.name === 'flutter') {
|
|
611
|
+
patterns = [FLUTTER_WIDGET_RE];
|
|
152
612
|
}
|
|
613
|
+
|
|
614
|
+
for (const pattern of patterns) {
|
|
615
|
+
const re = new RegExp(pattern.source, pattern.flags);
|
|
616
|
+
let m;
|
|
617
|
+
while ((m = re.exec(content)) !== null) {
|
|
618
|
+
const name = m[1];
|
|
619
|
+
if (seen.has(name)) continue;
|
|
620
|
+
seen.add(name);
|
|
621
|
+
routes.push({ method: 'SCREEN', path: name, file: rel });
|
|
622
|
+
if (routes.length >= SCREEN_LIMIT) return { routes };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
routes.sort((a, b) => a.path.localeCompare(b.path));
|
|
628
|
+
return { routes };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function detectRoutes(rootPath, framework) {
|
|
632
|
+
if (!framework) return { routes: [] };
|
|
633
|
+
|
|
634
|
+
// Mobile frameworks — detect screens instead of HTTP routes
|
|
635
|
+
if (['react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name)) {
|
|
636
|
+
return scanMobileScreens(rootPath, framework);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (framework.name === 'express') {
|
|
640
|
+
return scanExpressRoutes(rootPath);
|
|
641
|
+
}
|
|
642
|
+
if (framework.name === 'fastify') {
|
|
643
|
+
return scanFastifyRoutes(rootPath);
|
|
644
|
+
}
|
|
645
|
+
if (framework.name === 'hono') {
|
|
646
|
+
return scanHonoRoutes(rootPath);
|
|
647
|
+
}
|
|
648
|
+
if (framework.name === 'nestjs') {
|
|
649
|
+
return scanNestRoutes(rootPath);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (framework.name !== 'nextjs') {
|
|
153
653
|
return { routes: [] };
|
|
154
654
|
}
|
|
155
655
|
|
|
@@ -727,7 +1227,7 @@ async function extractFolderStructureHints(rootPath, framework) {
|
|
|
727
1227
|
/* skip */
|
|
728
1228
|
}
|
|
729
1229
|
}
|
|
730
|
-
} else if (framework && ['express', 'fastify', 'nestjs'].includes(framework.name)) {
|
|
1230
|
+
} else if (framework && ['express', 'fastify', 'hono', 'nestjs'].includes(framework.name)) {
|
|
731
1231
|
for (const base of ['src/routes', 'routes', 'src/controllers', 'src/modules']) {
|
|
732
1232
|
const root = path.join(rootPath, base);
|
|
733
1233
|
try {
|
|
@@ -867,11 +1367,17 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
|
|
|
867
1367
|
}
|
|
868
1368
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
869
1369
|
|
|
870
|
-
const framework = detectFramework(deps);
|
|
1370
|
+
const framework = detectFramework(deps, abs);
|
|
871
1371
|
log(`[scan] framework: ${framework.name} ${framework.version || ''}`);
|
|
872
1372
|
|
|
1373
|
+
const isMobile = ['react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name);
|
|
1374
|
+
|
|
873
1375
|
const schemaHints = await detectSchemaHints(abs);
|
|
874
|
-
|
|
1376
|
+
let orm = detectORM(deps, schemaHints);
|
|
1377
|
+
// For non-package.json mobile projects, fall back to filesystem-based ORM detection
|
|
1378
|
+
if (isMobile && orm.name === 'unknown') {
|
|
1379
|
+
orm = detectMobileORM(abs, framework);
|
|
1380
|
+
}
|
|
875
1381
|
log(`[scan] orm: ${orm.name}`);
|
|
876
1382
|
|
|
877
1383
|
const auth = detectAuth(deps);
|
|
@@ -886,7 +1392,13 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
|
|
|
886
1392
|
}
|
|
887
1393
|
log(`[scan] walk root: ${path.relative(abs, scanRoot) || '.'}`);
|
|
888
1394
|
|
|
889
|
-
|
|
1395
|
+
// Next.js routes live under src/app or app/ (walk handles both from scanRoot).
|
|
1396
|
+
// Express/Fastify/Hono/NestJS scanners specify their own candidate dirs
|
|
1397
|
+
// relative to project root, so pass `abs` for those frameworks.
|
|
1398
|
+
// Mobile frameworks also scan from project root for screen detection.
|
|
1399
|
+
const routeScanRoot =
|
|
1400
|
+
framework && ['express', 'fastify', 'hono', 'nestjs', 'react-native', 'ios-swift', 'android-kotlin', 'flutter'].includes(framework.name) ? abs : scanRoot;
|
|
1401
|
+
const { routes, note: routeNote } = await detectRoutes(routeScanRoot, framework);
|
|
890
1402
|
log(`[scan] routes: ${routes.length}${routeNote ? ` (${routeNote})` : ''}`);
|
|
891
1403
|
|
|
892
1404
|
const mutations = await detectMutations(scanRoot);
|
|
@@ -923,12 +1435,15 @@ async function scanRepo(rootPath, { log = () => {} } = {}) {
|
|
|
923
1435
|
/* skip */
|
|
924
1436
|
}
|
|
925
1437
|
|
|
926
|
-
const normalizedRoutes = (routes || []).flatMap((r) =>
|
|
927
|
-
|
|
1438
|
+
const normalizedRoutes = (routes || []).flatMap((r) => {
|
|
1439
|
+
// Express/Fastify/NestJS routes have { method, path } directly.
|
|
1440
|
+
if (r.method) return [{ method: r.method, path: r.path }];
|
|
1441
|
+
// Next.js routes have { methods: [...], path, file }.
|
|
1442
|
+
return (r.methods && r.methods.length ? r.methods : ['*']).map((method) => ({
|
|
928
1443
|
method,
|
|
929
1444
|
path: r.path,
|
|
930
|
-
}))
|
|
931
|
-
);
|
|
1445
|
+
}));
|
|
1446
|
+
});
|
|
932
1447
|
|
|
933
1448
|
const normalizedMutations = (mutations || []).map((m) => ({
|
|
934
1449
|
file: m.file,
|
|
@@ -971,8 +1486,13 @@ module.exports = {
|
|
|
971
1486
|
SCAN_VERSION,
|
|
972
1487
|
detectFramework,
|
|
973
1488
|
detectORM,
|
|
1489
|
+
detectMobileORM,
|
|
974
1490
|
detectAuth,
|
|
975
1491
|
detectRoutes,
|
|
1492
|
+
scanExpressRoutes,
|
|
1493
|
+
scanFastifyRoutes,
|
|
1494
|
+
scanNestRoutes,
|
|
1495
|
+
scanMobileScreens,
|
|
976
1496
|
detectMutations,
|
|
977
1497
|
scanRepo,
|
|
978
1498
|
// Phase 18.6 A1
|
|
@@ -37,6 +37,7 @@ function buildScriptTag(injection) {
|
|
|
37
37
|
` src="${injection.scriptSrc}"\n` +
|
|
38
38
|
` data-gurulu-site-id="${injection.siteId}"\n` +
|
|
39
39
|
` data-gurulu-tenant-id="${injection.tenantId}"${pkAttr}\n` +
|
|
40
|
+
` data-features="errors,replay,advanced"\n` +
|
|
40
41
|
` ${MARKER}\n` +
|
|
41
42
|
` async\n` +
|
|
42
43
|
` ></script>\n`
|