@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
@@ -237,6 +237,19 @@ function log(deps, level, msg) {
237
237
  return;
238
238
  l[level](msg);
239
239
  }
240
+ function getEnvPrefix(framework) {
241
+ if (framework.startsWith('nextjs'))
242
+ return 'NEXT_PUBLIC_';
243
+ if (framework === 'vite-react' || framework === 'vite')
244
+ return 'VITE_';
245
+ if (framework === 'nuxt' || framework === 'vue')
246
+ return 'NUXT_PUBLIC_';
247
+ if (framework === 'sveltekit' || framework === 'svelte')
248
+ return 'PUBLIC_';
249
+ if (framework === 'astro')
250
+ return 'PUBLIC_';
251
+ return ''; // Express, Fastify, NestJS don't need prefix
252
+ }
240
253
  async function runInstallFlow(args, deps, scriptsDir) {
241
254
  const repoRoot = path.resolve(args.path || process.cwd());
242
255
  const summary = {
@@ -310,6 +323,11 @@ async function runInstallFlow(args, deps, scriptsDir) {
310
323
  if (args.framework) {
311
324
  planArgs.push('--framework', args.framework);
312
325
  }
326
+ // Sprint A fix A2 — forward the active profile's secret key so the planner
327
+ // can call the LLM property-extraction endpoint while building the diff.
328
+ if (args.authToken) {
329
+ planArgs.push('--token', args.authToken);
330
+ }
313
331
  // Phase 18.7 B — include auto-instrument diff in the dry-run plan so the
314
332
  // consent prompt / preview shows both script-tag + track-call changes.
315
333
  if (args.autoInstrument) {
@@ -353,6 +371,14 @@ async function runInstallFlow(args, deps, scriptsDir) {
353
371
  if (args.framework) {
354
372
  applyArgs.push('--framework', args.framework);
355
373
  }
374
+ // Sprint A fix A2 — forward the active profile's secret key so the
375
+ // agentic-install script's `tryLlmExtraction` can authenticate against
376
+ // the LLM property-extraction endpoint. Without it the request 401s and
377
+ // every injected `gurulu.track(...)` falls back to `// TODO: <prop>`
378
+ // placeholders, breaking the auto-instrument feature.
379
+ if (args.authToken) {
380
+ applyArgs.push('--token', args.authToken);
381
+ }
356
382
  // Phase 18.7 B — forward auto-instrument flag + intent result path.
357
383
  if (args.autoInstrument) {
358
384
  applyArgs.push('--auto-instrument');
@@ -429,10 +455,11 @@ async function runInstallFlow(args, deps, scriptsDir) {
429
455
  }
430
456
  // ---- 6. .env merge ---------------------------------------------------
431
457
  const ingestUrl = args.ingestUrl || process.env.GURULU_INGEST_URL || 'https://gurulu.io';
458
+ const envPrefix = getEnvPrefix(detectedFw);
432
459
  const envVars = {
433
- NEXT_PUBLIC_GURULU_SITE_ID: args.siteId,
434
- NEXT_PUBLIC_GURULU_TENANT_ID: args.tenantId,
435
- NEXT_PUBLIC_GURULU_INGEST_URL: ingestUrl,
460
+ [`${envPrefix}GURULU_SITE_ID`]: args.siteId,
461
+ [`${envPrefix}GURULU_TENANT_ID`]: args.tenantId,
462
+ [`${envPrefix}GURULU_INGEST_URL`]: ingestUrl,
436
463
  };
437
464
  if (args.skipEnv || args.dryRun) {
438
465
  log(deps, 'info', 'Skipping .env merge.');
@@ -557,6 +584,32 @@ async function runInstallFlow(args, deps, scriptsDir) {
557
584
  console.log((0, ui_1.dim)(' │ ') + 'Docs: https://gurulu.io/docs/quick-start');
558
585
  console.log((0, ui_1.dim)(' └─────────────────────────────────────────────'));
559
586
  console.log('');
587
+ // Goals & funnels management
588
+ log(deps, 'info', `${(0, ui_1.cyan)('Goals & Funnels')}`);
589
+ log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu goals list')} ${(0, ui_1.dim)('List all conversion goals')}`);
590
+ log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu goals create')} ${(0, ui_1.dim)('Create a new goal interactively')}`);
591
+ log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu funnels create')} ${(0, ui_1.dim)('Define a multi-step funnel')}`);
592
+ console.log('');
593
+ // Server-side setup
594
+ log(deps, 'info', `${(0, ui_1.cyan)('Server-Side Tracking')}`);
595
+ log(deps, 'info', ` ${(0, ui_1.dim)('Add server-side event tracking with:')} ${(0, ui_1.cyan)('gurulu add-server')}`);
596
+ log(deps, 'info', ` ${(0, ui_1.dim)('Or manually:')}`);
597
+ log(deps, 'info', ` ${(0, ui_1.cyan)('npm install @gurulu/node')}`);
598
+ log(deps, 'info', ` ${(0, ui_1.dim)('import { Gurulu } from \'@gurulu/node\';')}`);
599
+ log(deps, 'info', ` ${(0, ui_1.dim)('const gurulu = new Gurulu({ siteId, serverApiKey });')}`);
600
+ log(deps, 'info', ` ${(0, ui_1.dim)('gurulu.track(\'order_completed\', { userId, revenue });')}`);
601
+ console.log('');
602
+ // DataLayer Bridge for GTM
603
+ log(deps, 'info', `${(0, ui_1.cyan)('DataLayer Bridge')} ${(0, ui_1.dim)('(for GTM users)')}`);
604
+ log(deps, 'info', ` ${(0, ui_1.dim)('Auto-capture all Google Tag Manager events:')}`);
605
+ log(deps, 'info', ` ${(0, ui_1.cyan)('window.gurulu.loadDataLayerBridge()')}`);
606
+ console.log('');
607
+ // identify() guidance
608
+ log(deps, 'info', `${(0, ui_1.cyan)('User Identification')}`);
609
+ log(deps, 'info', ` ${(0, ui_1.dim)('Web:')} ${(0, ui_1.cyan)('window.gurulu.identify(\'user_123\', { email, plan })')}`);
610
+ log(deps, 'info', ` ${(0, ui_1.dim)('Server:')} ${(0, ui_1.cyan)('gurulu.identify(\'user_123\', { email, plan })')}`);
611
+ log(deps, 'info', ` ${(0, ui_1.dim)('Call on login/signup to link events to known users.')}`);
612
+ console.log('');
560
613
  }
561
614
  return summary;
562
615
  }
@@ -807,6 +860,9 @@ async function runAuthenticatedInstallFlow(args, authDeps, installDeps, scriptsD
807
860
  ...filled,
808
861
  autoInstrument: autoInstrumentEnabled,
809
862
  intentResultPath,
863
+ // Sprint A fix A2 — propagate the active profile secret key so the core
864
+ // flow can forward it to the agentic-install script as `--token`.
865
+ authToken: authDeps.profile.secret_key,
810
866
  };
811
867
  // Run the core flow.
812
868
  const summary = await runInstallFlow(runArgs, installDeps, scriptsDir);
@@ -887,6 +943,13 @@ async function runAuthenticatedInstallFlow(args, authDeps, installDeps, scriptsD
887
943
  async function installCommand(args) {
888
944
  const scriptsDir = resolveScriptsDir();
889
945
  const deps = createDefaultDeps(scriptsDir);
946
+ // Sprint A fix A3 — make `--verify` opt-in (default false). The verifier
947
+ // (`scripts/gurulu-verify-install.lib.cjs:312`) hard-`require`s
948
+ // `playwright-core`, which is NOT a runtime dependency of @gurulu/cli, so a
949
+ // default-on verify aborts every fresh install with "Verify threw". Treat
950
+ // verify as strictly opt-in: only `args.verify === true` runs it. We also
951
+ // patch the CLI option default in `src/index.ts` so the flag matches.
952
+ args = { ...args, verify: args.verify === true };
890
953
  // Legacy unauthenticated path: --site-id passed explicitly and no active profile.
891
954
  const profile = await (0, config_1.getActiveProfile)({ profile: args.profile });
892
955
  const legacyMode = !!args.siteId && !profile;
@@ -900,7 +963,14 @@ async function installCommand(args) {
900
963
  if (!tenantId)
901
964
  tenantId = await deps.prompt(' Tenant ID: ');
902
965
  }
903
- const filled = { ...args, siteId, tenantId };
966
+ // Sprint A fix A2 legacy callers can supply a token via env so that
967
+ // auto-instrument property extraction still works without a profile.
968
+ const filled = {
969
+ ...args,
970
+ siteId,
971
+ tenantId,
972
+ authToken: args.authToken || process.env.GURULU_API_KEY || process.env.GURULU_SECRET_KEY,
973
+ };
904
974
  const summary = await runInstallFlow(filled, deps, scriptsDir);
905
975
  if (summary.errors.length > 0)
906
976
  process.exit(1);
@@ -1,21 +1,33 @@
1
1
  /**
2
2
  * `gurulu sourcemap upload` — Upload source maps for error deobfuscation.
3
3
  *
4
- * Reads all `.map` files from the specified directory and POSTs them to
5
- * `/api/errors/sourcemaps` with Bearer auth. The server stores them on
6
- * disk for later stack trace deobfuscation (follow-up task).
7
- *
8
- * Usage:
4
+ * Web (default):
9
5
  * gurulu sourcemap upload --release v1.0.0 --dir .next/static/
6
+ *
7
+ * Native (C3 — iOS dSYM / Android ProGuard):
8
+ * gurulu sourcemap upload --platform=ios --version=1.4.2 --bundle-id=com.example.app --file=app.xcarchive/dSYMs/MyApp.dSYM.zip
9
+ * gurulu sourcemap upload --platform=android --version=1.4.2 --bundle-id=com.example.app --file=app/build/outputs/mapping/release/mapping.txt
10
+ *
11
+ * The native variant POSTs a single `file` to `/api/sourcemap/native`, which
12
+ * persists it under {SOURCEMAP_STORAGE_PATH}/native/{siteId}/{platform}/{release}/
13
+ * and registers a `NativeSymbolFile` row.
10
14
  */
11
15
  import { loadActiveProfile } from '../config';
12
16
  export interface SourcemapArgs {
13
17
  action?: string;
14
18
  release?: string;
19
+ /** Alias of --release for native uploads. */
20
+ version?: string;
15
21
  dir?: string;
22
+ /** Native uploads: path to the dSYM zip or `mapping.txt`. */
23
+ file?: string;
24
+ /** Native uploads: iOS bundleId or Android applicationId. */
25
+ bundleId?: string;
26
+ appId?: string;
16
27
  site?: string;
17
28
  profile?: string;
18
29
  json?: boolean;
30
+ platform?: 'web' | 'server' | 'ios' | 'android';
19
31
  }
20
32
  export declare function sourcemapCommand(args: SourcemapArgs): Promise<void>;
21
33
  export declare const _loadActiveProfile: typeof loadActiveProfile;
@@ -2,12 +2,16 @@
2
2
  /**
3
3
  * `gurulu sourcemap upload` — Upload source maps for error deobfuscation.
4
4
  *
5
- * Reads all `.map` files from the specified directory and POSTs them to
6
- * `/api/errors/sourcemaps` with Bearer auth. The server stores them on
7
- * disk for later stack trace deobfuscation (follow-up task).
8
- *
9
- * Usage:
5
+ * Web (default):
10
6
  * gurulu sourcemap upload --release v1.0.0 --dir .next/static/
7
+ *
8
+ * Native (C3 — iOS dSYM / Android ProGuard):
9
+ * gurulu sourcemap upload --platform=ios --version=1.4.2 --bundle-id=com.example.app --file=app.xcarchive/dSYMs/MyApp.dSYM.zip
10
+ * gurulu sourcemap upload --platform=android --version=1.4.2 --bundle-id=com.example.app --file=app/build/outputs/mapping/release/mapping.txt
11
+ *
12
+ * The native variant POSTs a single `file` to `/api/sourcemap/native`, which
13
+ * persists it under {SOURCEMAP_STORAGE_PATH}/native/{siteId}/{platform}/{release}/
14
+ * and registers a `NativeSymbolFile` row.
11
15
  */
12
16
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
17
  if (k2 === undefined) k2 = k;
@@ -55,10 +59,71 @@ async function sourcemapCommand(args) {
55
59
  if (action !== 'upload') {
56
60
  (0, ui_1.error)(`Unknown sourcemap action: ${action}`);
57
61
  (0, ui_1.info)('Usage: gurulu sourcemap upload --release <version> --dir <path>');
62
+ (0, ui_1.info)(' gurulu sourcemap upload --platform=ios|android --version=<v> --bundle-id=<id> --file=<path>');
58
63
  process.exit(1);
59
64
  }
65
+ if (args.platform === 'ios' || args.platform === 'android') {
66
+ return uploadNativeCmd(args);
67
+ }
60
68
  return uploadCmd(args);
61
69
  }
70
+ async function uploadNativeCmd(args) {
71
+ const version = args.version || args.release;
72
+ if (!version) {
73
+ (0, ui_1.error)('Missing --version flag. Example: --version=1.4.2');
74
+ process.exit(1);
75
+ }
76
+ if (!args.file) {
77
+ (0, ui_1.error)('Missing --file flag. For iOS pass the dSYM zip; for Android pass mapping.txt');
78
+ process.exit(1);
79
+ }
80
+ const bundleId = args.bundleId || args.appId;
81
+ if (!bundleId) {
82
+ (0, ui_1.error)('Missing --bundle-id flag (iOS bundleId or Android applicationId)');
83
+ process.exit(1);
84
+ }
85
+ const filePath = path.resolve(args.file);
86
+ if (!fs.existsSync(filePath)) {
87
+ (0, ui_1.error)(`File not found: ${filePath}`);
88
+ process.exit(1);
89
+ }
90
+ const profile = await (0, config_1.loadActiveProfile)({ profile: args.profile });
91
+ const siteId = args.site || profile.site_id;
92
+ if (!siteId) {
93
+ (0, ui_1.error)('Missing --site flag or default site in profile.');
94
+ process.exit(1);
95
+ }
96
+ const fileName = path.basename(filePath);
97
+ const content = fs.readFileSync(filePath);
98
+ const blob = new Blob([content], { type: 'application/octet-stream' });
99
+ const formData = new FormData();
100
+ formData.append('platform', args.platform);
101
+ formData.append('version', version);
102
+ formData.append('bundleId', bundleId);
103
+ formData.append('siteId', siteId);
104
+ formData.append('file', blob, fileName);
105
+ (0, ui_1.info)(`Uploading ${fileName} (${args.platform}) for ${bundleId}@${version}...`);
106
+ const res = await (0, api_client_1.cliApi)('/api/sourcemap/native', {
107
+ method: 'POST',
108
+ body: formData,
109
+ profile: args.profile,
110
+ headers: {},
111
+ });
112
+ if (!res.ok) {
113
+ const body = await res.json().catch(() => ({ error: 'Unknown error' }));
114
+ (0, ui_1.error)(`Upload failed (${res.status}): ${body.error || res.statusText}`);
115
+ process.exit(1);
116
+ }
117
+ const result = await res.json();
118
+ if (args.json) {
119
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
120
+ return;
121
+ }
122
+ (0, ui_1.success)(`Native symbols uploaded for ${args.platform} ${version}`);
123
+ process.stdout.write((0, ui_1.dim)(` File: ${result.fileName}\n`));
124
+ process.stdout.write((0, ui_1.dim)(` Path: ${result.storagePath}\n`));
125
+ process.stdout.write((0, ui_1.dim)(` Size: ${result.sizeBytes} bytes\n`));
126
+ }
62
127
  function findMapFiles(dir) {
63
128
  const results = [];
64
129
  function walk(d) {
@@ -103,17 +168,19 @@ async function uploadCmd(args) {
103
168
  (0, ui_1.error)('Missing --site flag or default site in profile. Use: gurulu sourcemap upload --site <site-id> ...');
104
169
  process.exit(1);
105
170
  }
171
+ const platform = args.platform || 'web';
106
172
  // Build FormData
107
173
  const formData = new FormData();
108
174
  formData.append('release', args.release);
109
175
  formData.append('siteId', siteId);
176
+ formData.append('platform', platform);
110
177
  for (const filePath of mapFiles) {
111
178
  const content = fs.readFileSync(filePath);
112
179
  const fileName = path.basename(filePath);
113
180
  const blob = new Blob([content], { type: 'application/json' });
114
181
  formData.append(fileName, blob, fileName);
115
182
  }
116
- const res = await (0, api_client_1.cliApi)('/api/errors/sourcemaps', {
183
+ const res = await (0, api_client_1.cliApi)('/api/tracker-config/sourcemaps', {
117
184
  method: 'POST',
118
185
  body: formData,
119
186
  profile: args.profile,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Sprint B Group VII B14 — `gurulu watch` live event stream.
3
+ *
4
+ * Tail every event for the active tenant in near-real time, with optional
5
+ * filtering by site / event types. Uses the existing CLI SSE endpoint
6
+ * (/api/cli/events/tail) so we don't duplicate poll/heartbeat plumbing.
7
+ *
8
+ * gurulu watch # all sites, all events
9
+ * gurulu watch --site=site_123 # one site
10
+ * gurulu watch --types=$purchase,page_view # filter by event names (CSV)
11
+ * gurulu watch --tail=50 # show last N before streaming
12
+ * gurulu watch --json # one JSON line per event
13
+ *
14
+ * Auth: bearer token from active profile (`loadActiveProfile` via cliApi).
15
+ * Graceful shutdown: SIGINT cancels the fetch via AbortController.
16
+ */
17
+ export interface WatchArgs {
18
+ site?: string;
19
+ types?: string;
20
+ tail?: number;
21
+ json?: boolean;
22
+ profile?: string;
23
+ /** Override the upstream SSE path — for tests/proxies. Defaults to the CLI
24
+ * bearer-auth tail route. */
25
+ endpoint?: string;
26
+ }
27
+ interface RealtimeEventRow {
28
+ event_id?: string;
29
+ event_ts?: string;
30
+ event_name?: string;
31
+ site_id?: string;
32
+ site_name?: string;
33
+ url?: string;
34
+ page_url?: string;
35
+ anonymous_id?: string;
36
+ user_id?: string;
37
+ selector?: string;
38
+ revenue_value?: number | string;
39
+ revenue_currency?: string;
40
+ properties?: Record<string, unknown>;
41
+ [key: string]: unknown;
42
+ }
43
+ export declare function watchCommand(args: WatchArgs): Promise<void>;
44
+ export declare function formatEventLine(row: RealtimeEventRow, json: boolean): string;
45
+ export {};
@@ -0,0 +1,258 @@
1
+ "use strict";
2
+ /**
3
+ * Sprint B Group VII B14 — `gurulu watch` live event stream.
4
+ *
5
+ * Tail every event for the active tenant in near-real time, with optional
6
+ * filtering by site / event types. Uses the existing CLI SSE endpoint
7
+ * (/api/cli/events/tail) so we don't duplicate poll/heartbeat plumbing.
8
+ *
9
+ * gurulu watch # all sites, all events
10
+ * gurulu watch --site=site_123 # one site
11
+ * gurulu watch --types=$purchase,page_view # filter by event names (CSV)
12
+ * gurulu watch --tail=50 # show last N before streaming
13
+ * gurulu watch --json # one JSON line per event
14
+ *
15
+ * Auth: bearer token from active profile (`loadActiveProfile` via cliApi).
16
+ * Graceful shutdown: SIGINT cancels the fetch via AbortController.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.watchCommand = watchCommand;
20
+ exports.formatEventLine = formatEventLine;
21
+ const api_client_1 = require("../api-client");
22
+ const ui_1 = require("../utils/ui");
23
+ async function watchCommand(args) {
24
+ const types = parseTypes(args.types);
25
+ const tailCount = Math.max(0, Number(args.tail) || 0);
26
+ // 1. Optional tail of recent events before streaming.
27
+ if (tailCount > 0) {
28
+ await printTail(args, types, tailCount);
29
+ }
30
+ // 2. Open the SSE stream and forward events until SIGINT.
31
+ await streamEvents(args, types);
32
+ }
33
+ function parseTypes(raw) {
34
+ if (!raw)
35
+ return null;
36
+ const list = raw
37
+ .split(',')
38
+ .map((s) => s.trim())
39
+ .filter((s) => s.length > 0);
40
+ if (list.length === 0)
41
+ return null;
42
+ return new Set(list);
43
+ }
44
+ async function printTail(args, types, tailCount) {
45
+ const qs = new URLSearchParams();
46
+ if (args.site)
47
+ qs.set('site', args.site);
48
+ // The tail-history endpoint accepts a single event_name. When the user
49
+ // requested multiple, fetch the union and filter client-side.
50
+ if (types && types.size === 1) {
51
+ qs.set('event_name', Array.from(types)[0]);
52
+ }
53
+ qs.set('limit', String(Math.min(tailCount * (types ? 5 : 1), 500)));
54
+ qs.set('since', '24h');
55
+ const path = `/api/cli/events?${qs.toString()}`;
56
+ let body;
57
+ try {
58
+ body = await (0, api_client_1.cliApiJson)(path, {
59
+ profile: args.profile,
60
+ });
61
+ }
62
+ catch (err) {
63
+ (0, ui_1.error)(`Failed to fetch tail history: ${err.message}`);
64
+ return;
65
+ }
66
+ const all = body.events || [];
67
+ const filtered = (types ? all.filter((e) => e.event_name && types.has(e.event_name)) : all)
68
+ .slice(-tailCount) // keep the newest N (events arrive newest-first)
69
+ .reverse();
70
+ if (filtered.length === 0) {
71
+ if (!args.json)
72
+ (0, ui_1.info)('No prior events in the last 24h.');
73
+ }
74
+ for (const ev of filtered) {
75
+ process.stdout.write(formatEventLine(ev, !!args.json));
76
+ }
77
+ if (!args.json && filtered.length > 0) {
78
+ process.stdout.write((0, ui_1.dim)(' --- live stream ---\n'));
79
+ }
80
+ }
81
+ async function streamEvents(args, types) {
82
+ const qs = new URLSearchParams();
83
+ if (args.site)
84
+ qs.set('site', args.site);
85
+ // SSE backend accepts a single eventName. Multi-type filter is applied on
86
+ // the client. (Forwarded as a hint when only one is set so the server can
87
+ // skip irrelevant rows entirely.)
88
+ if (types && types.size === 1)
89
+ qs.set('event_name', Array.from(types)[0]);
90
+ const path = args.endpoint ||
91
+ `/api/cli/events/tail${qs.toString() ? `?${qs.toString()}` : ''}`;
92
+ // SIGINT → abort the fetch so the SSE socket is released cleanly.
93
+ const controller = new AbortController();
94
+ const onSigint = () => {
95
+ if (!args.json)
96
+ process.stderr.write((0, ui_1.dim)('\n[watch] disconnected\n'));
97
+ controller.abort();
98
+ // Give the abort path a tick to drain, then exit normally.
99
+ setTimeout(() => process.exit(0), 25);
100
+ };
101
+ process.on('SIGINT', onSigint);
102
+ process.on('SIGTERM', onSigint);
103
+ if (!args.json) {
104
+ (0, ui_1.info)(`Streaming events${args.site ? ` for site=${args.site}` : ''}${types ? ` types=[${Array.from(types).join(',')}]` : ''} — Ctrl+C to stop`);
105
+ }
106
+ // Reconnect loop — SSE streams cap at 10 minutes server-side. Honour the
107
+ // `reconnect` frame and any transient network drop.
108
+ let attempt = 0;
109
+ while (!controller.signal.aborted) {
110
+ try {
111
+ const res = await (0, api_client_1.cliApi)(path, {
112
+ profile: args.profile,
113
+ headers: { accept: 'text/event-stream' },
114
+ signal: controller.signal,
115
+ });
116
+ if (!res.body) {
117
+ (0, ui_1.error)('Watch stream returned no body.');
118
+ return;
119
+ }
120
+ attempt = 0; // reset backoff on successful connect
121
+ await consumeSseBody(res.body, types, !!args.json);
122
+ // Stream ended cleanly (max_lifetime). Re-open immediately.
123
+ }
124
+ catch (err) {
125
+ if (controller.signal.aborted)
126
+ return;
127
+ attempt += 1;
128
+ const wait = Math.min(15_000, 500 * 2 ** attempt);
129
+ if (!args.json) {
130
+ process.stderr.write((0, ui_1.dim)(`[watch] connection lost (${err.message}); reconnecting in ${wait}ms\n`));
131
+ }
132
+ await sleep(wait);
133
+ }
134
+ }
135
+ }
136
+ async function consumeSseBody(body, types, json) {
137
+ // Browser-style ReadableStream
138
+ const reader = body.getReader?.();
139
+ if (reader) {
140
+ const decoder = new TextDecoder();
141
+ let buffer = '';
142
+ // eslint-disable-next-line no-constant-condition
143
+ while (true) {
144
+ const { done, value } = await reader.read();
145
+ if (done)
146
+ return;
147
+ buffer += decoder.decode(value, { stream: true });
148
+ const flushed = drainSseBuffer(buffer);
149
+ buffer = flushed.tail;
150
+ for (const frame of flushed.frames)
151
+ emitFrame(frame, types, json);
152
+ }
153
+ }
154
+ // Node Readable fallback
155
+ let buffer = '';
156
+ for await (const chunk of body) {
157
+ buffer += Buffer.from(chunk).toString('utf8');
158
+ const flushed = drainSseBuffer(buffer);
159
+ buffer = flushed.tail;
160
+ for (const frame of flushed.frames)
161
+ emitFrame(frame, types, json);
162
+ }
163
+ }
164
+ function drainSseBuffer(buffer) {
165
+ const frames = [];
166
+ let idx = buffer.indexOf('\n\n');
167
+ while (idx !== -1) {
168
+ const block = buffer.slice(0, idx);
169
+ buffer = buffer.slice(idx + 2);
170
+ let event = 'message';
171
+ let data = '';
172
+ for (const line of block.split('\n')) {
173
+ if (line.startsWith('event: '))
174
+ event = line.slice(7).trim();
175
+ else if (line.startsWith('data: '))
176
+ data += line.slice(6);
177
+ }
178
+ if (data)
179
+ frames.push({ event, data });
180
+ idx = buffer.indexOf('\n\n');
181
+ }
182
+ return { frames, tail: buffer };
183
+ }
184
+ function emitFrame(frame, types, json) {
185
+ if (frame.event !== 'realtime_event')
186
+ return;
187
+ let row;
188
+ try {
189
+ row = JSON.parse(frame.data);
190
+ }
191
+ catch {
192
+ return;
193
+ }
194
+ if (types && row.event_name && !types.has(row.event_name))
195
+ return;
196
+ process.stdout.write(formatEventLine(row, json));
197
+ }
198
+ function formatEventLine(row, json) {
199
+ if (json)
200
+ return JSON.stringify(row) + '\n';
201
+ const ts = formatTimestamp(row.event_ts);
202
+ const name = String(row.event_name || '-').padEnd(14);
203
+ const namePainted = paintEvent(name, String(row.event_name || ''));
204
+ const who = formatActor(row);
205
+ const site = row.site_name || row.site_id ? `site=${row.site_name || row.site_id}` : '';
206
+ const tail = formatTail(row);
207
+ return `${(0, ui_1.dim)(ts)} ${namePainted} ${who}${site ? ' ' + (0, ui_1.dim)(site) : ''}${tail ? ' ' + tail : ''}\n`;
208
+ }
209
+ function formatTimestamp(ts) {
210
+ if (!ts)
211
+ return '----------------'.padEnd(19);
212
+ // ClickHouse format: '2026-04-27 14:23:01' (UTC) or ISO. Take the first 19.
213
+ const cleaned = ts.replace('T', ' ').slice(0, 19);
214
+ return cleaned.padEnd(19);
215
+ }
216
+ function paintEvent(padded, raw) {
217
+ if (!raw)
218
+ return padded;
219
+ if (raw.startsWith('$'))
220
+ return (0, ui_1.cyan)(padded); // system events
221
+ if (raw.startsWith('error'))
222
+ return (0, ui_1.yellow)(padded);
223
+ if (raw === 'page_view' || raw === 'pageview')
224
+ return (0, ui_1.dim)(padded);
225
+ return (0, ui_1.green)(padded);
226
+ }
227
+ function formatActor(row) {
228
+ if (row.user_id)
229
+ return `${(0, ui_1.bold)('user')}=${String(row.user_id).slice(0, 8)}`;
230
+ if (row.anonymous_id)
231
+ return `anon=${String(row.anonymous_id).slice(0, 6)}`;
232
+ return (0, ui_1.dim)('anon=?');
233
+ }
234
+ function formatTail(row) {
235
+ const parts = [];
236
+ const url = row.url || row.page_url;
237
+ if (url)
238
+ parts.push(stripHost(String(url)));
239
+ if (row.selector)
240
+ parts.push(`selector=${row.selector}`);
241
+ if (row.revenue_value != null && Number(row.revenue_value) > 0) {
242
+ const cur = row.revenue_currency || 'USD';
243
+ parts.push(`value=${row.revenue_value} ${cur}`);
244
+ }
245
+ return parts.join(' ');
246
+ }
247
+ function stripHost(url) {
248
+ try {
249
+ const u = new URL(url);
250
+ return u.pathname + (u.search || '');
251
+ }
252
+ catch {
253
+ return url;
254
+ }
255
+ }
256
+ function sleep(ms) {
257
+ return new Promise((resolve) => setTimeout(resolve, ms));
258
+ }
@@ -11,25 +11,47 @@ const path_1 = __importDefault(require("path"));
11
11
  function detectFramework(projectDir) {
12
12
  const pkgPath = path_1.default.join(projectDir, 'package.json');
13
13
  const hasPkgJson = fs_1.default.existsSync(pkgPath);
14
+ // Read package.json early so we can dedupe iOS-vs-React-Native (RN repos
15
+ // commonly host an `ios/MyApp.xcodeproj` and a top-level Package.swift in
16
+ // monorepos — without the RN check first we'd return `ios-swift` and ship
17
+ // the wrong SDK snippet).
18
+ let deps = {};
19
+ if (hasPkgJson) {
20
+ try {
21
+ const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
22
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
23
+ }
24
+ catch {
25
+ /* malformed package.json — fall through to filesystem detection */
26
+ }
27
+ if (deps['react-native'])
28
+ return 'react-native';
29
+ }
14
30
  // Mobile framework detection (non-package.json based)
15
31
  if (fs_1.default.existsSync(path_1.default.join(projectDir, 'pubspec.yaml')))
16
32
  return 'flutter';
17
33
  const hasSwiftPkg = fs_1.default.existsSync(path_1.default.join(projectDir, 'Package.swift'));
18
- const hasXcodeProj = fs_1.default.readdirSync(projectDir).some(f => f.endsWith('.xcodeproj'));
34
+ let hasXcodeProj = false;
35
+ try {
36
+ hasXcodeProj = fs_1.default
37
+ .readdirSync(projectDir)
38
+ .some((f) => f.endsWith('.xcodeproj') || f.endsWith('.xcworkspace'));
39
+ }
40
+ catch {
41
+ /* unreadable dir — treat as no Xcode project */
42
+ }
19
43
  if (hasSwiftPkg || hasXcodeProj)
20
44
  return 'ios-swift';
21
45
  if (!hasPkgJson) {
22
46
  // Android detection (no package.json means no RN false positive)
23
- if (fs_1.default.existsSync(path_1.default.join(projectDir, 'build.gradle.kts')) || fs_1.default.existsSync(path_1.default.join(projectDir, 'build.gradle'))) {
47
+ if (fs_1.default.existsSync(path_1.default.join(projectDir, 'build.gradle.kts')) ||
48
+ fs_1.default.existsSync(path_1.default.join(projectDir, 'build.gradle')) ||
49
+ fs_1.default.existsSync(path_1.default.join(projectDir, 'app', 'build.gradle')) ||
50
+ fs_1.default.existsSync(path_1.default.join(projectDir, 'app', 'build.gradle.kts'))) {
24
51
  return 'android-kotlin';
25
52
  }
26
53
  return 'html';
27
54
  }
28
- const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
29
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
30
- // React Native (must check before other React frameworks)
31
- if (deps['react-native'])
32
- return 'react-native';
33
55
  if (deps['next']) {
34
56
  // Check for app router
35
57
  if (fs_1.default.existsSync(path_1.default.join(projectDir, 'src', 'app')) || fs_1.default.existsSync(path_1.default.join(projectDir, 'app'))) {