@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.
Files changed (39) hide show
  1. package/README.md +61 -24
  2. package/dist/api-client.js +1 -1
  3. package/dist/commands/add-server.js +13 -6
  4. package/dist/commands/alerts.d.ts +5 -0
  5. package/dist/commands/alerts.js +43 -15
  6. package/dist/commands/audiences.d.ts +3 -0
  7. package/dist/commands/audiences.js +34 -7
  8. package/dist/commands/events.d.ts +6 -0
  9. package/dist/commands/events.js +182 -1
  10. package/dist/commands/experiments.d.ts +4 -0
  11. package/dist/commands/experiments.js +46 -15
  12. package/dist/commands/funnels.d.ts +17 -0
  13. package/dist/commands/funnels.js +203 -0
  14. package/dist/commands/goals.d.ts +18 -0
  15. package/dist/commands/goals.js +214 -0
  16. package/dist/commands/install.d.ts +8 -0
  17. package/dist/commands/install.js +74 -4
  18. package/dist/commands/sourcemap.d.ts +17 -5
  19. package/dist/commands/sourcemap.js +73 -6
  20. package/dist/commands/watch.d.ts +45 -0
  21. package/dist/commands/watch.js +258 -0
  22. package/dist/frameworks/detect.js +29 -7
  23. package/dist/index.js +158 -13
  24. package/package.json +1 -1
  25. package/scripts/gurulu-agentic-install.mjs +225 -0
  26. package/scripts/gurulu-scan.lib.cjs +539 -19
  27. package/scripts/patches/astro.patch.cjs +1 -0
  28. package/scripts/patches/auto-instrument/hono.cjs +381 -0
  29. package/scripts/patches/auto-instrument/index.cjs +2 -0
  30. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +13 -4
  31. package/scripts/patches/express.patch.cjs +2 -2
  32. package/scripts/patches/fastify.patch.cjs +1 -0
  33. package/scripts/patches/nestjs.patch.cjs +1 -0
  34. package/scripts/patches/nextjs-app-router.patch.cjs +2 -2
  35. package/scripts/patches/nextjs-pages.patch.cjs +1 -0
  36. package/scripts/patches/remix.patch.cjs +1 -0
  37. package/scripts/patches/sveltekit.patch.cjs +1 -0
  38. package/scripts/patches/vite-react.patch.cjs +1 -0
  39. package/scripts/patches/vue.patch.cjs +1 -0
package/dist/index.js CHANGED
@@ -30,12 +30,17 @@ const identity_1 = require("./commands/identity");
30
30
  const playground_1 = require("./commands/playground");
31
31
  // Phase 20 W3 C4 — audit log tail/export
32
32
  const audit_1 = require("./commands/audit");
33
+ // Goals & Funnels CRUD
34
+ const goals_1 = require("./commands/goals");
35
+ const funnels_1 = require("./commands/funnels");
33
36
  // Gurulu Chat — NL → SQL analytics
34
37
  const chat_1 = require("./commands/chat");
35
38
  // Error tracking — source map upload
36
39
  const sourcemap_1 = require("./commands/sourcemap");
37
40
  // Phase 21 — database connect
38
41
  const db_1 = require("./commands/db");
42
+ // Sprint B Group VII B14 — live event stream
43
+ const watch_1 = require("./commands/watch");
39
44
  (0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
40
45
  .scriptName('gurulu')
41
46
  .option('profile', { type: 'string', describe: 'Use a specific profile (default: personal)' })
@@ -153,13 +158,19 @@ const db_1 = require("./commands/db");
153
158
  yes: args.yes,
154
159
  profile: args.profile,
155
160
  }))
156
- .command('events <action>', 'View events ingested by Gurulu (list, tail)', (y) => y
157
- .positional('action', { type: 'string', describe: 'list | tail' })
161
+ .command('events <action>', 'Manage events (list, tail, schema, define, verify, templates)', (y) => y
162
+ .positional('action', { type: 'string', describe: 'list | tail | schema | define | verify | templates' })
158
163
  .option('site', { type: 'string', describe: 'Site ID' })
159
- .option('event-name', { type: 'string', describe: 'Filter by event name' })
164
+ .option('event-name', { type: 'string', describe: 'Event name (for schema/define/list)' })
160
165
  .option('filter', { type: 'string', describe: 'Filter expression (event_name:...)' })
161
166
  .option('since', { type: 'string', describe: 'ISO timestamp or 1h/24h/7d' })
162
167
  .option('limit', { type: 'number', describe: 'Max rows (1..500)' })
168
+ .option('display-name', { type: 'string', describe: 'Human-readable display name (define)' })
169
+ .option('description', { type: 'string', describe: 'Event description (define)' })
170
+ .option('category', { type: 'string', describe: 'Category: acquisition|activation|retention|revenue|referral|compliance|support|engagement (define)' })
171
+ .option('properties', { type: 'string', describe: 'Property schema as JSON array (define)' })
172
+ .option('event', { type: 'string', describe: 'Check a specific event only (verify)' })
173
+ .option('vertical', { type: 'string', describe: 'Business vertical for templates (ecommerce, saas, fintech, igaming, etc.)' })
163
174
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => {
164
175
  (0, events_1.eventsCommand)({
165
176
  action: args.action,
@@ -170,6 +181,12 @@ const db_1 = require("./commands/db");
170
181
  limit: args.limit,
171
182
  json: args.json,
172
183
  profile: args.profile,
184
+ displayName: args['display-name'],
185
+ description: args.description,
186
+ category: args.category,
187
+ properties: args.properties,
188
+ event: args.event,
189
+ vertical: args.vertical,
173
190
  });
174
191
  })
175
192
  .command('status', 'Check SDK health and connection', (y) => y
@@ -208,7 +225,7 @@ const db_1 = require("./commands/db");
208
225
  .option('skip-env', { type: 'boolean', describe: 'Skip .env file merge' })
209
226
  .option('yes', { type: 'boolean', alias: 'y', describe: 'Non-interactive (assume yes)' })
210
227
  .option('ingest-url', { type: 'string', describe: 'Override ingest base URL' })
211
- .option('verify', { type: 'boolean', default: true, describe: 'Live smoke test after install' })
228
+ .option('verify', { type: 'boolean', default: false, describe: 'Live smoke test after install (requires playwright-core)' })
212
229
  .option('skip-intent', { type: 'boolean', describe: 'Skip install-time intent discovery' })
213
230
  .option('intent-dry-run', { type: 'boolean', describe: 'Show intent proposal without pre-seeding' })
214
231
  .option('vertical', { type: 'string', describe: 'Vertical hint for intent analyzer' })
@@ -269,12 +286,28 @@ const db_1 = require("./commands/db");
269
286
  });
270
287
  })
271
288
  // ── Phase 19.5 W2 — read-surface subcommands ─────────────────────────
272
- .command('audiences <action> [target]', 'View audiences (list, show)', (y) => y
273
- .positional('action', { type: 'string', describe: 'list | show' })
289
+ .command('audiences <action> [target]', 'Manage audiences (list, show, create, update, delete)', (y) => y
290
+ .positional('action', { type: 'string', describe: 'list | show | create | update | delete' })
274
291
  .positional('target', { type: 'string', describe: 'Audience name or id' })
292
+ .option('site', { type: 'string', describe: 'Site ID' })
293
+ .option('id', { type: 'string', describe: 'Audience ID (for update/delete)' })
294
+ .option('name', { type: 'string', describe: 'Audience name' })
295
+ .option('description', { type: 'string', describe: 'Audience description' })
296
+ .option('rules', { type: 'string', describe: 'Audience rules (JSON array)' })
297
+ .option('from-file', { type: 'string', describe: 'Load payload from JSON file' })
298
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be done' })
299
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
275
300
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, audiences_1.audiencesCommand)({
276
301
  action: args.action,
277
302
  target: args.target,
303
+ site: args.site,
304
+ id: args.id,
305
+ name: args.name,
306
+ description: args.description,
307
+ rules: args.rules,
308
+ fromFile: args['from-file'],
309
+ dryRun: args['dry-run'],
310
+ yes: args.yes,
278
311
  json: args.json,
279
312
  profile: args.profile,
280
313
  }))
@@ -287,20 +320,44 @@ const db_1 = require("./commands/db");
287
320
  json: args.json,
288
321
  profile: args.profile,
289
322
  }))
290
- .command('alerts <action> [sub] [target]', 'View anomaly alerts (list, show <id>, channels list)', (y) => y
291
- .positional('action', { type: 'string', describe: 'list | show | channels' })
323
+ .command('alerts <action> [sub] [target]', 'Manage alerts (list, show, create, update, delete, channels)', (y) => y
324
+ .positional('action', { type: 'string', describe: 'list | show | create | update | delete | channels' })
292
325
  .positional('sub', { type: 'string', describe: 'Subaction (e.g. list for channels)' })
293
326
  .positional('target', { type: 'string', describe: 'Alert id (for show)' })
327
+ .option('site', { type: 'string', describe: 'Site ID' })
328
+ .option('id', { type: 'string', describe: 'Alert ID (for update/delete)' })
329
+ .option('name', { type: 'string', describe: 'Alert rule name' })
330
+ .option('type', { type: 'string', describe: 'Alert type (threshold, anomaly)' })
331
+ .option('metric', { type: 'string', describe: 'Alert metric' })
332
+ .option('threshold-type', { type: 'string', describe: 'Threshold type (count_per_minute, value, etc.)' })
333
+ .option('threshold-value', { type: 'number', describe: 'Threshold value' })
334
+ .option('channel', { type: 'string', describe: 'Channel ID for alert delivery' })
294
335
  .option('severity', { type: 'string', describe: 'low | medium | high | critical' })
295
336
  .option('acknowledged', { type: 'string', describe: 'true | false' })
337
+ .option('note', { type: 'string', describe: 'Note for update' })
296
338
  .option('limit', { type: 'number', describe: 'Max rows (1..200)' })
339
+ .option('from-file', { type: 'string', describe: 'Load payload from JSON file' })
340
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be done' })
341
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
297
342
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, alerts_1.alertsCommand)({
298
343
  action: args.action,
299
344
  sub: args.sub,
300
345
  target: args.target,
346
+ site: args.site,
347
+ id: args.id,
348
+ name: args.name,
349
+ type: args.type,
350
+ metric: args.metric,
351
+ thresholdType: args['threshold-type'],
352
+ thresholdValue: args['threshold-value'],
353
+ channel: args.channel,
301
354
  severity: args.severity,
302
355
  acknowledged: args.acknowledged,
356
+ note: args.note,
303
357
  limit: args.limit,
358
+ fromFile: args['from-file'],
359
+ dryRun: args['dry-run'],
360
+ yes: args.yes,
304
361
  json: args.json,
305
362
  profile: args.profile,
306
363
  }))
@@ -322,14 +379,82 @@ const db_1 = require("./commands/db");
322
379
  json: args.json,
323
380
  profile: args.profile,
324
381
  }))
325
- .command('experiments <action> [target]', 'View experiments (list, show <key>, results <key>)', (y) => y
326
- .positional('action', { type: 'string', describe: 'list | show | results' })
382
+ .command('experiments <action> [target]', 'Manage experiments (list, show, results, create, update, delete, start, stop)', (y) => y
383
+ .positional('action', { type: 'string', describe: 'list | show | results | create | update | delete | start | stop' })
327
384
  .positional('target', { type: 'string', describe: 'Experiment key or id' })
385
+ .option('site', { type: 'string', describe: 'Site ID' })
386
+ .option('id', { type: 'string', describe: 'Experiment ID (for update/delete)' })
387
+ .option('key', { type: 'string', describe: 'Experiment key (for create)' })
388
+ .option('name', { type: 'string', describe: 'Experiment name' })
389
+ .option('description', { type: 'string', describe: 'Experiment description' })
390
+ .option('variants', { type: 'string', describe: 'Variants as JSON array' })
391
+ .option('goal', { type: 'string', describe: 'Goal ID to track conversions' })
328
392
  .option('conversion-event', { type: 'string', describe: 'Conversion event name' })
393
+ .option('from-file', { type: 'string', describe: 'Load payload from JSON file' })
394
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be done' })
395
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
329
396
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, experiments_1.experimentsCommand)({
330
397
  action: args.action,
331
398
  target: args.target,
399
+ site: args.site,
400
+ id: args.id,
401
+ key: args.key,
402
+ name: args.name,
403
+ description: args.description,
404
+ variants: args.variants,
405
+ goal: args.goal,
332
406
  conversionEvent: args['conversion-event'],
407
+ fromFile: args['from-file'],
408
+ dryRun: args['dry-run'],
409
+ yes: args.yes,
410
+ json: args.json,
411
+ profile: args.profile,
412
+ }))
413
+ .command('goals <action> [target]', 'Manage conversion goals (list, show, create, update, delete)', (y) => y
414
+ .positional('action', { type: 'string', describe: 'list | show | create | update | delete' })
415
+ .positional('target', { type: 'string', describe: 'Goal id' })
416
+ .option('site', { type: 'string', describe: 'Site ID (for list/create)' })
417
+ .option('name', { type: 'string', describe: 'Goal name' })
418
+ .option('description', { type: 'string', describe: 'Goal description' })
419
+ .option('event', { type: 'string', describe: 'Event name for the goal' })
420
+ .option('properties-filter', { type: 'string', describe: 'Properties filter (JSON)' })
421
+ .option('from-file', { type: 'string', describe: 'Load payload from JSON file' })
422
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be done' })
423
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
424
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, goals_1.goalsCommand)({
425
+ action: args.action,
426
+ target: args.target,
427
+ site: args.site,
428
+ name: args.name,
429
+ description: args.description,
430
+ event: args.event,
431
+ propertiesFilter: args['properties-filter'],
432
+ fromFile: args['from-file'],
433
+ dryRun: args['dry-run'],
434
+ yes: args.yes,
435
+ json: args.json,
436
+ profile: args.profile,
437
+ }))
438
+ .command('funnels <action> [target]', 'Manage conversion funnels (list, show, create, update, delete)', (y) => y
439
+ .positional('action', { type: 'string', describe: 'list | show | create | update | delete' })
440
+ .positional('target', { type: 'string', describe: 'Funnel id' })
441
+ .option('site', { type: 'string', describe: 'Site ID (for list/create)' })
442
+ .option('name', { type: 'string', describe: 'Funnel name' })
443
+ .option('description', { type: 'string', describe: 'Funnel description' })
444
+ .option('steps', { type: 'string', describe: 'Comma-separated event names for funnel steps' })
445
+ .option('from-file', { type: 'string', describe: 'Load payload from JSON file' })
446
+ .option('dry-run', { type: 'boolean', describe: 'Show what would be done' })
447
+ .option('yes', { type: 'boolean', alias: 'y', describe: 'Skip confirmation' })
448
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, funnels_1.funnelsCommand)({
449
+ action: args.action,
450
+ target: args.target,
451
+ site: args.site,
452
+ name: args.name,
453
+ description: args.description,
454
+ steps: args.steps,
455
+ fromFile: args['from-file'],
456
+ dryRun: args['dry-run'],
457
+ yes: args.yes,
333
458
  json: args.json,
334
459
  profile: args.profile,
335
460
  }))
@@ -386,17 +511,25 @@ const db_1 = require("./commands/db");
386
511
  profile: args.profile,
387
512
  }))
388
513
  // ── Error tracking — source map upload ────────────────────────────────
389
- .command('sourcemap <action>', 'Upload source maps for error deobfuscation (upload)', (y) => y
514
+ .command('sourcemap <action>', 'Upload source maps for error deobfuscation (upload). Supports web/server JS .map and native dSYM/ProGuard.', (y) => y
390
515
  .positional('action', { type: 'string', describe: 'upload' })
391
- .option('release', { type: 'string', describe: 'Release version (e.g. v1.0.0)' })
392
- .option('dir', { type: 'string', describe: 'Directory containing .map files' })
516
+ .option('release', { type: 'string', describe: 'Release version (e.g. v1.0.0). Web/server only — alias of --version.' })
517
+ .option('version', { type: 'string', describe: 'App version (e.g. 1.4.2). Required for native uploads.' })
518
+ .option('dir', { type: 'string', describe: 'Directory containing .map files (web/server)' })
519
+ .option('file', { type: 'string', describe: 'Path to dSYM zip (iOS) or mapping.txt (Android)' })
520
+ .option('bundle-id', { type: 'string', describe: 'iOS bundleId or Android applicationId (native only)' })
393
521
  .option('site', { type: 'string', describe: 'Site ID' })
522
+ .option('platform', { type: 'string', choices: ['web', 'server', 'ios', 'android'], default: 'web', describe: 'Platform: web, server, ios, or android' })
394
523
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, sourcemap_1.sourcemapCommand)({
395
524
  action: args.action,
396
525
  release: args.release,
526
+ version: args.version,
397
527
  dir: args.dir,
528
+ file: args.file,
529
+ bundleId: args['bundle-id'],
398
530
  site: args.site,
399
531
  json: args.json,
532
+ platform: args.platform,
400
533
  profile: args.profile,
401
534
  }))
402
535
  // ── Phase 21 — database connect ────────────────────────────────────────
@@ -426,6 +559,18 @@ const db_1 = require("./commands/db");
426
559
  noInteractive: args['no-interactive'],
427
560
  json: args.json,
428
561
  profile: args.profile,
562
+ }))
563
+ // ── Sprint B Group VII B14 — live event tail ─────────────────────────
564
+ .command('watch', 'Stream events live for the active tenant (SSE)', (y) => y
565
+ .option('site', { type: 'string', describe: 'Filter by site ID' })
566
+ .option('types', { type: 'string', describe: 'Comma-separated event names (e.g. $purchase,page_view)' })
567
+ .option('tail', { type: 'number', describe: 'Show last N events from the past 24h before streaming' })
568
+ .option('json', { type: 'boolean', describe: 'Emit one JSON line per event' }), (args) => (0, watch_1.watchCommand)({
569
+ site: args.site,
570
+ types: args.types,
571
+ tail: args.tail,
572
+ json: args.json,
573
+ profile: args.profile,
429
574
  }))
430
575
  .demandCommand(1, 'Run gurulu --help for available commands')
431
576
  .strict()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gurulu/cli",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"
@@ -16,6 +16,7 @@ import { createRequire } from 'node:module';
16
16
  import { readFileSync, statSync, existsSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { posix as posixPath } from 'node:path';
19
20
 
20
21
  const require = createRequire(import.meta.url);
21
22
  const lib = require('./gurulu-agentic-install.lib.cjs');
@@ -92,6 +93,8 @@ function printHelp(out = process.stdout) {
92
93
  ' --framework <f> auto|nextjs-app|nextjs-pages|vite-react|express',
93
94
  ' --auto-instrument (Phase 18.7) Also write gurulu.track() calls into route handlers',
94
95
  ' --intent-result <path> JSON file with InstallIntent output (feeds --auto-instrument)',
96
+ ' --token <key> CLI auth token for LLM property extraction API',
97
+ ' --api-url <url> Base URL for the Gurulu API (default: https://gurulu.io)',
95
98
  ' --quiet Suppress non-error output',
96
99
  ' -h, --help Show this help',
97
100
  '',
@@ -109,6 +112,156 @@ async function readScanInput(scanFile) {
109
112
  return JSON.parse(readFileSync(scanFile, 'utf8'));
110
113
  }
111
114
 
115
+ /**
116
+ * Return a placeholder JS expression for a property that lacks an example
117
+ * value. The placeholder includes a TODO comment so developers know to fill
118
+ * in the real expression from their route handler context.
119
+ */
120
+ function getPropertyPlaceholder(type, name) {
121
+ switch (type) {
122
+ case 'string': return `'' /* TODO: ${name} */`;
123
+ case 'number': return `0 /* TODO: ${name} */`;
124
+ case 'boolean': return `false /* TODO: ${name} */`;
125
+ default: return `undefined /* TODO: ${name} */`;
126
+ }
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Route resolution: map "POST /api/checkout" → actual file path on disk.
131
+ // Reuses the candidate patterns from the auto-instrument modules.
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function parseRouteString(routeStr) {
135
+ if (!routeStr || typeof routeStr !== 'string') return null;
136
+ const m = routeStr.trim().match(/^([A-Z]+)\s+(\/.*)$/);
137
+ if (!m) return null;
138
+ return { method: m[1].toUpperCase(), urlPath: m[2].replace(/\/+$/, '').split('?')[0] };
139
+ }
140
+
141
+ function resolveRouteFile(routeStr, framework, repoRoot) {
142
+ const parsed = parseRouteString(routeStr);
143
+ if (!parsed) return null;
144
+ const { urlPath } = parsed;
145
+ const segments = urlPath.split('/').filter(Boolean);
146
+
147
+ // Build candidate file paths based on framework.
148
+ const candidates = [];
149
+
150
+ // Next.js App Router patterns.
151
+ if (!framework || /next/i.test(framework)) {
152
+ const exts = ['ts', 'tsx', 'js', 'jsx'];
153
+ for (const ext of exts) {
154
+ candidates.push(posixPath.join('src', 'app', ...segments, `route.${ext}`));
155
+ candidates.push(posixPath.join('app', ...segments, `route.${ext}`));
156
+ }
157
+ }
158
+
159
+ // Next.js Pages API patterns.
160
+ if (!framework || /next.*pages/i.test(framework)) {
161
+ const exts = ['ts', 'tsx', 'js', 'jsx'];
162
+ for (const ext of exts) {
163
+ candidates.push(posixPath.join('src', 'pages', ...segments, `index.${ext}`));
164
+ candidates.push(posixPath.join('pages', ...segments, `index.${ext}`));
165
+ candidates.push(posixPath.join('src', 'pages', ...segments.slice(0, -1), `${segments[segments.length - 1]}.${ext}`));
166
+ candidates.push(posixPath.join('pages', ...segments.slice(0, -1), `${segments[segments.length - 1]}.${ext}`));
167
+ }
168
+ }
169
+
170
+ // Express / Fastify / generic patterns.
171
+ if (!framework || /express|fastify|nest|generic/i.test(framework)) {
172
+ const roots = ['src/routes', 'routes', 'src/api', 'api', 'src/controllers', 'controllers', 'src', 'app'];
173
+ const exts = ['ts', 'js', 'mjs', 'cjs'];
174
+ for (const root of roots) {
175
+ for (const ext of exts) {
176
+ // e.g. src/routes/checkout.ts
177
+ candidates.push(posixPath.join(root, ...segments.slice(1), `index.${ext}`));
178
+ if (segments.length > 1) {
179
+ candidates.push(posixPath.join(root, ...segments.slice(1, -1), `${segments[segments.length - 1]}.${ext}`));
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // SvelteKit patterns.
186
+ if (!framework || /svelte/i.test(framework)) {
187
+ const exts = ['ts', 'js'];
188
+ for (const ext of exts) {
189
+ candidates.push(posixPath.join('src', 'routes', ...segments, `+server.${ext}`));
190
+ }
191
+ }
192
+
193
+ // Remix patterns.
194
+ if (!framework || /remix/i.test(framework)) {
195
+ const exts = ['ts', 'tsx', 'js', 'jsx'];
196
+ for (const ext of exts) {
197
+ candidates.push(posixPath.join('app', 'routes', segments.join('.') + `.${ext}`));
198
+ }
199
+ }
200
+
201
+ // Find first existing candidate.
202
+ for (const rel of candidates) {
203
+ const abs = path.join(repoRoot, rel);
204
+ if (existsSync(abs)) return abs;
205
+ }
206
+
207
+ return null;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // LLM property extraction via the server API endpoint.
212
+ // ---------------------------------------------------------------------------
213
+
214
+ async function tryLlmExtraction(events, { repoRoot, framework, ingestUrl, token, siteId, log }) {
215
+ let llmHits = 0;
216
+ for (const event of events) {
217
+ // Skip if already has extracted properties.
218
+ if (event.extractedProperties && event.extractedProperties.length > 0) continue;
219
+
220
+ const routeStr = event.source && event.source.route;
221
+ if (!routeStr) continue;
222
+
223
+ const routeFile = resolveRouteFile(routeStr, framework, repoRoot);
224
+ if (!routeFile) continue;
225
+
226
+ let handlerSource;
227
+ try {
228
+ handlerSource = readFileSync(routeFile, 'utf-8');
229
+ } catch {
230
+ continue;
231
+ }
232
+
233
+ // Cap at 4KB to keep LLM calls reasonable.
234
+ const clipped = handlerSource.slice(0, 4096);
235
+
236
+ try {
237
+ const res = await fetch(`${ingestUrl}/api/cli/install/extract-properties`, {
238
+ method: 'POST',
239
+ headers: {
240
+ Authorization: `Bearer ${token}`,
241
+ 'Content-Type': 'application/json',
242
+ },
243
+ body: JSON.stringify({
244
+ siteId,
245
+ eventName: event.name,
246
+ handlerSource: clipped,
247
+ framework,
248
+ }),
249
+ });
250
+ if (res.ok) {
251
+ const data = await res.json();
252
+ if (data.properties && data.properties.length > 0 && data.confidence >= 0.7) {
253
+ event.extractedProperties = data.properties;
254
+ llmHits++;
255
+ log(` LLM extracted ${data.properties.length} property(ies) for ${event.name} (confidence: ${data.confidence.toFixed(2)})`);
256
+ }
257
+ }
258
+ } catch {
259
+ // LLM extraction failed — fall through to placeholder.
260
+ }
261
+ }
262
+ return llmHits;
263
+ }
264
+
112
265
  async function runPatchMode(repoRoot, args) {
113
266
  const log = args.quiet ? () => {} : (m) => process.stdout.write(m + '\n');
114
267
  if (args.rollback) {
@@ -160,6 +313,41 @@ async function runPatchMode(repoRoot, args) {
160
313
  const intent = JSON.parse(readFileSync(args['intent-result'], 'utf8'));
161
314
  const acceptedEvents = (intent && intent.accepted && intent.accepted.events) || [];
162
315
  const frameworkName = (patcher && patcher.name) || framework;
316
+
317
+ // Phase 20 — Try LLM-based property extraction first.
318
+ const ingestUrl = args['ingest-url'] || args['api-url'] || 'https://gurulu.io';
319
+ const token = args['token'] || args['api-key'] || '';
320
+ const siteId = args['site-id'] || '';
321
+ if (token) {
322
+ try {
323
+ const hits = await tryLlmExtraction(acceptedEvents, {
324
+ repoRoot,
325
+ framework: frameworkName,
326
+ ingestUrl,
327
+ token,
328
+ siteId,
329
+ log,
330
+ });
331
+ if (hits > 0) log(`LLM property extraction: ${hits} event(s) enriched`);
332
+ } catch (err) {
333
+ log(`LLM property extraction failed (using placeholders): ${err.message || err}`);
334
+ }
335
+ }
336
+
337
+ // Placeholder fallback for events that LLM didn't cover.
338
+ for (const event of acceptedEvents) {
339
+ if (event.extractedProperties && event.extractedProperties.length > 0) continue;
340
+ if (!event.propertySchema || !event.propertySchema.length) continue;
341
+ event.extractedProperties = event.propertySchema
342
+ .filter((p) => p.required)
343
+ .map((p) => ({
344
+ name: p.name,
345
+ type: p.type || 'unknown',
346
+ source: p.example !== undefined && p.example !== null
347
+ ? JSON.stringify(p.example)
348
+ : getPropertyPlaceholder(p.type, p.name),
349
+ }));
350
+ }
163
351
  const aiResult = patches.autoInstrumentDispatch(
164
352
  frameworkName,
165
353
  { repoRoot },
@@ -212,7 +400,44 @@ async function runPatchMode(repoRoot, args) {
212
400
  log('Auto-instrument: no accepted events to instrument');
213
401
  return;
214
402
  }
403
+
215
404
  const frameworkName = (patcher && patcher.name) || framework;
405
+
406
+ // Phase 20 — Try LLM-based property extraction first.
407
+ const ingestUrl = args['ingest-url'] || args['api-url'] || 'https://gurulu.io';
408
+ const token = args['token'] || args['api-key'] || '';
409
+ const siteId = args['site-id'] || '';
410
+ if (token) {
411
+ try {
412
+ const hits = await tryLlmExtraction(acceptedEvents, {
413
+ repoRoot,
414
+ framework: frameworkName,
415
+ ingestUrl,
416
+ token,
417
+ siteId,
418
+ log,
419
+ });
420
+ if (hits > 0) log(`LLM property extraction: ${hits} event(s) enriched`);
421
+ } catch (err) {
422
+ log(`LLM property extraction failed (using placeholders): ${err.message || err}`);
423
+ }
424
+ }
425
+
426
+ // Placeholder fallback: enrich events with extractedProperties from
427
+ // propertySchema for any events the LLM didn't cover.
428
+ for (const event of acceptedEvents) {
429
+ if (event.extractedProperties && event.extractedProperties.length > 0) continue;
430
+ if (!event.propertySchema || !event.propertySchema.length) continue;
431
+ event.extractedProperties = event.propertySchema
432
+ .filter((p) => p.required)
433
+ .map((p) => ({
434
+ name: p.name,
435
+ type: p.type || 'unknown',
436
+ source: p.example !== undefined && p.example !== null
437
+ ? JSON.stringify(p.example)
438
+ : getPropertyPlaceholder(p.type, p.name),
439
+ }));
440
+ }
216
441
  const aiResult = patches.autoInstrumentDispatch(
217
442
  frameworkName,
218
443
  { repoRoot },