@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
package/dist/commands/install.js
CHANGED
|
@@ -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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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/
|
|
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
|
-
|
|
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')) ||
|
|
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'))) {
|