@gurulu/cli 0.4.5 → 0.4.7

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.
@@ -12,6 +12,12 @@ export interface CliApiOptions extends RequestInit {
12
12
  preloadedProfile?: ActiveProfile;
13
13
  /** Disable process.exit for tests. */
14
14
  noExitOnError?: boolean;
15
+ /**
16
+ * Surface raw error bodies on 5xx instead of the generic "temporarily
17
+ * unavailable" message. Caller should pass true when --json / --show-sql
18
+ * / any verbose flag is set. GURULU_DEBUG=1 also forces verbose globally.
19
+ */
20
+ verbose?: boolean;
15
21
  }
16
22
  export declare class CliApiError extends Error {
17
23
  status: number;
@@ -129,6 +129,31 @@ async function cliApi(path, init = {}) {
129
129
  fatal(`Rate limited by Gurulu${retry ? ` — try again in ${retry}s` : ''}. Try again shortly.`, init);
130
130
  }
131
131
  if (res.status >= 500) {
132
+ const verbose = init.verbose === true || process.env.GURULU_DEBUG === '1';
133
+ if (verbose) {
134
+ let detail = '';
135
+ try {
136
+ const body = await res.clone().json();
137
+ const msg = body?.error || body?.message;
138
+ if (msg) {
139
+ detail = ` ${typeof msg === 'string' ? msg : JSON.stringify(msg)}`;
140
+ }
141
+ else {
142
+ detail = ` ${JSON.stringify(body)}`;
143
+ }
144
+ }
145
+ catch {
146
+ try {
147
+ const txt = await res.clone().text();
148
+ if (txt)
149
+ detail = ` ${txt.slice(0, 500)}`;
150
+ }
151
+ catch {
152
+ /* ignore */
153
+ }
154
+ }
155
+ fatal(`Gurulu is temporarily unavailable (HTTP ${res.status}).${detail}`, init);
156
+ }
132
157
  fatal(`Gurulu is temporarily unavailable (HTTP ${res.status}).`, init);
133
158
  }
134
159
  return res;
@@ -54,22 +54,38 @@ const login_1 = require("./login");
54
54
  const config_1 = require("../config");
55
55
  const api_client_1 = require("../api-client");
56
56
  const ui_1 = require("../utils/ui");
57
+ function isHeadless() {
58
+ if (!process.stdout.isTTY)
59
+ return true;
60
+ if (process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.SSH_CONNECTION)
61
+ return true;
62
+ if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
63
+ return true;
64
+ return false;
65
+ }
57
66
  function tryOpenBrowser(url) {
58
67
  const platform = process.platform;
59
68
  const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open';
69
+ // On Windows the URL goes as a positional after an empty title arg to `start`.
70
+ const args = platform === 'win32' ? ['', url] : [url];
60
71
  try {
61
- const child = (0, child_process_1.spawn)(cmd, [url], {
72
+ const child = (0, child_process_1.spawn)(cmd, args, {
62
73
  detached: true,
63
74
  stdio: 'ignore',
64
75
  shell: platform === 'win32',
65
76
  });
77
+ let errored = false;
66
78
  child.on('error', () => {
67
- /* ignore — fall back to manual */
79
+ errored = true;
68
80
  });
69
81
  child.unref();
82
+ // spawn() returns synchronously; if the binary is missing on PATH we'll
83
+ // catch the synchronous throw below. The async 'error' handler covers
84
+ // post-fork failures but we can't await it here without blocking the flow.
85
+ return !errored;
70
86
  }
71
87
  catch {
72
- /* ignore — we printed the URL already */
88
+ return false;
73
89
  }
74
90
  }
75
91
  function sleep(ms) {
@@ -121,8 +137,17 @@ async function authCommand(args) {
121
137
  console.log(` ${(0, ui_1.dim)('Visit:')} ${verifyUrl}`);
122
138
  console.log(` ${(0, ui_1.dim)('Device:')} CLI — ${deviceLabel}`);
123
139
  console.log('');
124
- if (!args.noBrowser) {
125
- tryOpenBrowser(verifyUrl);
140
+ if (args.noBrowser) {
141
+ (0, ui_1.info)(`Open this URL manually: ${verifyUrl}`);
142
+ }
143
+ else if (isHeadless()) {
144
+ (0, ui_1.info)(`Headless/SSH session detected — open this URL on another device: ${verifyUrl}`);
145
+ }
146
+ else {
147
+ const opened = tryOpenBrowser(verifyUrl);
148
+ if (!opened) {
149
+ (0, ui_1.info)(`Could not auto-open the browser. Open this URL manually: ${verifyUrl}`);
150
+ }
126
151
  }
127
152
  const expiresAtMs = new Date(expiresAt).getTime();
128
153
  const interval = Math.max(1000, pollIntervalMs || 2000);
@@ -195,16 +220,26 @@ async function authCommand(args) {
195
220
  process.exit(1);
196
221
  }
197
222
  const fullKey = approved.secretKey.fullKey;
223
+ const userRequestedKeychain = args.useKeychain === true;
224
+ const keychainAvailable = (0, config_1.isKeychainAvailable)();
225
+ if (userRequestedKeychain && !keychainAvailable) {
226
+ (0, ui_1.warn)('Keychain requested via --keychain but the optional `keytar` module is not installed. ' +
227
+ 'Falling back to plaintext storage in ~/.gurulu/config.json. ' +
228
+ 'Install `keytar` (`npm i -g keytar`) or unset --keychain to silence this warning.');
229
+ }
198
230
  const { storedInKeychain } = await (0, config_1.saveProfile)(profileName, {
199
231
  email: `cli-${deviceLabel}@gurulu.local`,
200
232
  secret_key: fullKey,
201
233
  api_base: apiBase,
202
- }, { useKeychain: args.useKeychain ?? (0, config_1.isKeychainAvailable)() });
234
+ }, { useKeychain: args.useKeychain ?? keychainAvailable });
203
235
  const tail = fullKey.slice(-6);
204
236
  (0, ui_1.success)(`Device paired. Key …${tail} saved to profile "${profileName}".`);
205
237
  if (storedInKeychain) {
206
238
  (0, ui_1.info)(` Stored in macOS Keychain (${(0, ui_1.dim)('io.gurulu.cli')})`);
207
239
  }
240
+ else if (userRequestedKeychain) {
241
+ (0, ui_1.info)(` Stored in plaintext at ~/.gurulu/config.json (keychain unavailable).`);
242
+ }
208
243
  if (approved.secretKey.expiresAt) {
209
244
  (0, ui_1.info)(` Expires: ${approved.secretKey.expiresAt}`);
210
245
  }
@@ -14,5 +14,6 @@ export interface ChatArgs {
14
14
  showSql?: boolean;
15
15
  profile?: string;
16
16
  context?: string;
17
+ site?: string;
17
18
  }
18
19
  export declare function chatCommand(args: ChatArgs): Promise<void>;
@@ -54,6 +54,7 @@ async function askQuestion(question, args) {
54
54
  json: {
55
55
  question,
56
56
  context: args.context,
57
+ ...(args.site ? { siteId: args.site } : {}),
57
58
  },
58
59
  });
59
60
  }
@@ -23,6 +23,9 @@ async function doctorCommand(args) {
23
23
  const checks = [];
24
24
  // 1. Framework detection
25
25
  const framework = (0, detect_1.detectFramework)(projectDir);
26
+ // If the user explicitly passed --site, the cwd-derived checks should not
27
+ // imply that the *production site* is broken — they're informational only.
28
+ const isLocalProjectDetected = framework !== 'unknown';
26
29
  checks.push({
27
30
  name: 'Framework Detection',
28
31
  status: framework !== 'unknown' ? 'pass' : 'warn',
@@ -30,6 +33,7 @@ async function doctorCommand(args) {
30
33
  ? `Detected ${(0, detect_1.getFrameworkDisplayName)(framework)}`
31
34
  : 'Could not detect framework',
32
35
  fix: framework === 'unknown' ? 'Run "gurulu init --framework <name>" to specify manually' : undefined,
36
+ section: 'local',
33
37
  });
34
38
  // 2. SDK installed
35
39
  const pkgPath = path_1.default.join(projectDir, 'package.json');
@@ -78,18 +82,35 @@ async function doctorCommand(args) {
78
82
  : hasAndroidSDK ? 'Android SDK found in build.gradle'
79
83
  : setupFiles.length > 0 ? `Setup file found: ${setupFiles[0]}`
80
84
  : 'No client SDK detected';
85
+ // When --site was passed but cwd is not a Gurulu project, downgrade local
86
+ // SDK failures to "skip" so they don't masquerade as production-site issues.
87
+ const localChecksAreInformational = !!siteId && !isLocalProjectDetected;
81
88
  checks.push({
82
89
  name: 'Client SDK',
83
- status: hasSetup ? 'pass' : 'fail',
84
- message: sdkLabel,
85
- fix: !hasSetup ? 'Run "gurulu init" to set up analytics' : undefined,
90
+ status: hasSetup ? 'pass' : localChecksAreInformational ? 'skip' : 'fail',
91
+ message: hasSetup
92
+ ? sdkLabel
93
+ : localChecksAreInformational
94
+ ? 'No client SDK detected in this directory (informational — site checks below)'
95
+ : sdkLabel,
96
+ fix: !hasSetup && !localChecksAreInformational
97
+ ? 'Run "gurulu init" to set up analytics'
98
+ : undefined,
99
+ section: 'local',
86
100
  });
87
101
  // 3. Server SDK
88
102
  checks.push({
89
103
  name: 'Server SDK',
90
- status: hasNodeSDK ? 'pass' : 'warn',
91
- message: hasNodeSDK ? '@gurulu/node installed' : 'Server SDK not installed (optional)',
92
- fix: !hasNodeSDK ? 'Run "gurulu add-server" for server-side tracking' : undefined,
104
+ status: hasNodeSDK ? 'pass' : localChecksAreInformational ? 'skip' : 'warn',
105
+ message: hasNodeSDK
106
+ ? '@gurulu/node installed'
107
+ : localChecksAreInformational
108
+ ? 'Server SDK check skipped (no local project detected)'
109
+ : 'Server SDK not installed (optional)',
110
+ fix: !hasNodeSDK && !localChecksAreInformational
111
+ ? 'Run "gurulu add-server" for server-side tracking'
112
+ : undefined,
113
+ section: 'local',
93
114
  });
94
115
  // 4. Site ID configured
95
116
  checks.push({
@@ -263,6 +284,7 @@ async function doctorCommand(args) {
263
284
  status: envHasSiteId ? 'pass' : 'warn',
264
285
  message: envHasSiteId ? 'Site ID found in env file' : 'No GURULU_SITE_ID in env files',
265
286
  fix: !envHasSiteId ? 'Run "gurulu init" to auto-configure .env.local' : undefined,
287
+ section: 'local',
266
288
  });
267
289
  }
268
290
  // Output
@@ -281,16 +303,36 @@ async function doctorCommand(args) {
281
303
  };
282
304
  let failCount = 0;
283
305
  let warnCount = 0;
284
- for (const check of checks) {
285
- const icon = icons[check.status];
286
- console.log(` ${icon} ${(0, ui_1.bold)(check.name)}: ${check.message}`);
287
- if (check.fix) {
288
- console.log(` ${(0, ui_1.dim)('\u2192')} ${(0, ui_1.dim)(check.fix)}`);
306
+ const localChecks = checks.filter((c) => c.section === 'local');
307
+ const siteChecks = checks.filter((c) => c.section !== 'local');
308
+ const printGroup = (title, items, note) => {
309
+ if (items.length === 0)
310
+ return;
311
+ console.log(` ${(0, ui_1.bold)(title)}`);
312
+ if (note)
313
+ console.log(` ${(0, ui_1.dim)(note)}`);
314
+ for (const check of items) {
315
+ const icon = icons[check.status];
316
+ console.log(` ${icon} ${(0, ui_1.bold)(check.name)}: ${check.message}`);
317
+ if (check.fix) {
318
+ console.log(` ${(0, ui_1.dim)('\u2192')} ${(0, ui_1.dim)(check.fix)}`);
319
+ }
320
+ if (check.status === 'fail')
321
+ failCount++;
322
+ if (check.status === 'warn')
323
+ warnCount++;
289
324
  }
290
- if (check.status === 'fail')
291
- failCount++;
292
- if (check.status === 'warn')
293
- warnCount++;
325
+ console.log('');
326
+ };
327
+ if (localChecks.length > 0) {
328
+ const localNote = siteId && !isLocalProjectDetected
329
+ ? `(Local project not detected \u2014 running site-only checks for ${siteId}.)`
330
+ : undefined;
331
+ printGroup('Local Project', localChecks, localNote);
332
+ }
333
+ if (siteChecks.length > 0) {
334
+ const siteTitle = siteId ? `Site (${siteId})` : 'Site';
335
+ printGroup(siteTitle, siteChecks);
294
336
  }
295
337
  console.log('');
296
338
  if (failCount === 0 && warnCount === 0) {
@@ -76,10 +76,10 @@ async function listCmd(args) {
76
76
  process.stdout.write(['TS', 'EVENT', 'SITE', 'URL'].join('\t') + '\n');
77
77
  for (const e of events) {
78
78
  process.stdout.write([
79
- String(e.event_ts || '-'),
79
+ String(e.timestamp || e.event_ts || '-'),
80
80
  String(e.event_name || '-'),
81
81
  String(e.site_id || '-'),
82
- String(e.url || '-').slice(0, 60),
82
+ String(e.page_url || e.url || '-').slice(0, 60),
83
83
  ].join('\t') + '\n');
84
84
  }
85
85
  }
@@ -248,11 +248,33 @@ async function verifyCmd(args) {
248
248
  console.log(`Unique event types: ${data.unique_types ?? 'N/A'}`);
249
249
  }
250
250
  else {
251
- // Empty: no telemetry yet for the requested filter / definition. Without
252
- // this branch the report printed only its header users couldn't tell
253
- // success from "no data found".
251
+ // Phase 33 P33-WA three-state empty output instead of one generic
252
+ // "no telemetry yet" message. Helps the user tell apart "definition
253
+ // exists but never matched" vs "definition missing entirely" vs "no
254
+ // events at all on this site". Definition lookup is best-effort
255
+ // (extra HTTP call) so a 4xx falls through to the legacy message.
254
256
  if (filterEvent) {
255
- (0, ui_1.info)(`No telemetry yet for "${filterEvent}". Track it from your app and run verify again.`);
257
+ let definitionExists = null;
258
+ try {
259
+ const defs = await (0, api_client_1.cliApiJson)(`/api/cli/events/definitions?siteId=${encodeURIComponent(args.site)}`, { profile: args.profile });
260
+ definitionExists = Array.isArray(defs.definitions)
261
+ && defs.definitions.some((d) => d.eventName === filterEvent);
262
+ }
263
+ catch {
264
+ // best-effort — fall through to legacy message
265
+ }
266
+ if (definitionExists === true) {
267
+ (0, ui_1.info)(`✓ "${filterEvent}" is registered (catalog has it) but no telemetry has matched in the last 24h.`);
268
+ (0, ui_1.info)(` Likely cause: SDK hasn't fired this event yet. Check your client code and run verify again.`);
269
+ }
270
+ else if (definitionExists === false) {
271
+ (0, ui_1.info)(`⚠ "${filterEvent}" is NOT registered yet (no CustomEventDefinition row).`);
272
+ (0, ui_1.info)(` Phase 32 auto-registers on first sight, but you can also run:`);
273
+ (0, ui_1.info)(` gurulu events define --site ${args.site} --event-name ${filterEvent} --display-name "..."`);
274
+ }
275
+ else {
276
+ (0, ui_1.info)(`No telemetry yet for "${filterEvent}". Track it from your app and run verify again.`);
277
+ }
256
278
  }
257
279
  else {
258
280
  (0, ui_1.info)('No telemetry data found for this site in the last 24h.');
@@ -20,6 +20,18 @@ async function insightsCommand(args) {
20
20
  process.exit(1);
21
21
  }
22
22
  }
23
+ function formatHighlight(h) {
24
+ if (typeof h === 'string')
25
+ return h;
26
+ if (h && typeof h === 'object') {
27
+ const o = h;
28
+ const arrow = o.trend === 'up' ? '↑' : o.trend === 'down' ? '↓' : '→';
29
+ const value = typeof o.value === 'number' ? o.value.toLocaleString() : String(o.value ?? '');
30
+ const delta = typeof o.delta === 'number' ? ` (${o.delta >= 0 ? '+' : ''}${o.delta.toFixed(1)}% ${arrow})` : '';
31
+ return `${o.label ?? 'metric'}: ${value}${delta}`;
32
+ }
33
+ return String(h);
34
+ }
23
35
  async function todayCmd(args) {
24
36
  try {
25
37
  const body = await (0, api_client_1.cliApiJson)('/api/cli/insights', {
@@ -35,7 +47,7 @@ async function todayCmd(args) {
35
47
  if (Array.isArray(i.highlights)) {
36
48
  process.stdout.write(`Highlights:\n`);
37
49
  for (const h of i.highlights)
38
- process.stdout.write(` - ${h}\n`);
50
+ process.stdout.write(` - ${formatHighlight(h)}\n`);
39
51
  }
40
52
  }
41
53
  catch (err) {
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 2026-05-05 DF-5 — `gurulu setup --vertical saas` zero-friction lifecycle.
3
+ *
4
+ * Tek komutta:
5
+ * - vertical event taxonomy'sini define et (idempotent)
6
+ * - default funnel'ları kur
7
+ * - acquisition + revenue + retention event'lerine goal bağla
8
+ *
9
+ * 23 ayrı komut yerine 1.
10
+ */
11
+ export interface SetupArgs {
12
+ vertical?: string;
13
+ site?: string;
14
+ includeFunnels?: boolean;
15
+ includeGoals?: boolean;
16
+ dryRun?: boolean;
17
+ json?: boolean;
18
+ yes?: boolean;
19
+ profile?: string;
20
+ }
21
+ export declare function setupCommand(args: SetupArgs): Promise<void>;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /**
3
+ * 2026-05-05 DF-5 — `gurulu setup --vertical saas` zero-friction lifecycle.
4
+ *
5
+ * Tek komutta:
6
+ * - vertical event taxonomy'sini define et (idempotent)
7
+ * - default funnel'ları kur
8
+ * - acquisition + revenue + retention event'lerine goal bağla
9
+ *
10
+ * 23 ayrı komut yerine 1.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.setupCommand = setupCommand;
14
+ const api_client_1 = require("../api-client");
15
+ const ui_1 = require("../utils/ui");
16
+ async function setupCommand(args) {
17
+ const vertical = (args.vertical || '').toLowerCase().trim();
18
+ if (!vertical) {
19
+ (0, ui_1.error)('--vertical required (e.g. saas, ecommerce, marketplace, fintech, healthcare, education, media, igaming, generic)');
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ if (!args.site) {
24
+ (0, ui_1.error)('--site required');
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const path = '/api/cli/setup/apply-template' + (args.dryRun ? '?dry_run=1' : '');
29
+ const body = {
30
+ siteId: args.site,
31
+ vertical,
32
+ includeFunnels: args.includeFunnels !== false,
33
+ includeGoals: args.includeGoals !== false,
34
+ };
35
+ try {
36
+ const res = await (0, api_client_1.cliApiJson)(path, {
37
+ profile: args.profile,
38
+ method: 'POST',
39
+ json: body,
40
+ });
41
+ if (args.json) {
42
+ process.stdout.write(JSON.stringify(res, null, 2) + '\n');
43
+ return;
44
+ }
45
+ if ('dryRun' in res && res.dryRun) {
46
+ const a = res.after;
47
+ (0, ui_1.info)(`Dry-run preview — ${a.vertical} lifecycle taxonomy:`);
48
+ process.stdout.write(`\n Events (${a.events.length}): ${a.events.join(', ')}\n`);
49
+ process.stdout.write(` Funnels (${a.funnels.length}): ${a.funnels.join(', ')}\n`);
50
+ process.stdout.write(` Goals (${a.goals.length}): ${a.goals.join(', ')}\n\n`);
51
+ (0, ui_1.info)('Re-run without --dry-run to apply.');
52
+ return;
53
+ }
54
+ const r = res;
55
+ (0, ui_1.success)(`Applied ${r.vertical} lifecycle taxonomy to site ${args.site}`);
56
+ process.stdout.write(` Events: ${r.events.created.length} created, ${r.events.skipped.length} skipped\n`);
57
+ process.stdout.write(` Funnels: ${r.funnels.created.length} created, ${r.funnels.skipped.length} skipped\n`);
58
+ process.stdout.write(` Goals: ${r.goals.created.length} created, ${r.goals.skipped.length} skipped\n`);
59
+ if (r.events.created.length) {
60
+ process.stdout.write(`\n New events: ${r.events.created.join(', ')}\n`);
61
+ }
62
+ }
63
+ catch (err) {
64
+ (0, ui_1.error)(`Setup failed: ${err?.message ?? String(err)}`);
65
+ process.exitCode = 1;
66
+ }
67
+ }
@@ -31,7 +31,10 @@ async function whoamiCommand(args) {
31
31
  const sites = (me.sites || []).length;
32
32
  const keys = (me.apiKeys || []).length;
33
33
  const installs = me.quota?.installsThisMonth ?? 0;
34
- const limit = me.quota?.installsLimit ?? '∞';
34
+ const rawLimit = me.quota?.installsLimit;
35
+ const limit = rawLimit === -1 || rawLimit === null || rawLimit === undefined
36
+ ? 'unlimited'
37
+ : rawLimit;
35
38
  process.stdout.write(`${me.user?.email} · ${me.tenant?.name} · ${me.tenant?.plan} plan · ${me.tenant?.id}\n`);
36
39
  process.stdout.write(`Sites: ${sites} | API keys: ${keys} | Installs this month: ${installs}/${limit}\n`);
37
40
  }
@@ -232,26 +232,33 @@ export function initGurulu() {
232
232
  case 'express':
233
233
  return {
234
234
  file: 'src/gurulu.ts',
235
- code: `// Gurulu.io Server Analytics
235
+ code: `// Gurulu.io Server Analytics — Phase 32 P32-A2 fix
236
+ // Use the @gurulu/node SDK so retry, identity propagation, and the
237
+ // correct ingest endpoint (/api/ingest/v1/server) are handled for you.
238
+ // npm install @gurulu/node
239
+ import { Gurulu } from '@gurulu/node';
236
240
  import type { Request, Response, NextFunction } from 'express';
237
241
 
238
- const SITE_ID = '${siteId}';
239
- const TOKEN = '${token}';
242
+ export const gurulu = new Gurulu({
243
+ siteId: '${siteId}',
244
+ token: '${token}',
245
+ endpoint: 'https://gurulu.io',
246
+ });
240
247
 
241
248
  export function guruluMiddleware(req: Request, res: Response, next: NextFunction) {
242
- // Track server-side pageview
243
- fetch('https://ingest.gurulu.io/api/events', {
244
- method: 'POST',
245
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
246
- body: JSON.stringify({
247
- site_id: SITE_ID,
248
- event: 'pageview',
249
+ // Identity propagation: read the anonymous_id the browser SDK set in a
250
+ // cookie / header so server-side events stitch onto the same user.
251
+ const anonymousId = (req.headers['x-gurulu-anonymous-id'] as string)
252
+ || (req.cookies?._gurulu_id as string)
253
+ || undefined;
254
+ gurulu.track({
255
+ eventName: 'pageview',
256
+ anonymousId,
257
+ properties: {
249
258
  url: req.originalUrl,
250
259
  referrer: req.headers.referer || '',
251
260
  user_agent: req.headers['user-agent'] || '',
252
- ip: req.ip,
253
- timestamp: new Date().toISOString(),
254
- }),
261
+ },
255
262
  }).catch(() => {});
256
263
  next();
257
264
  }`,
@@ -260,26 +267,28 @@ export function guruluMiddleware(req: Request, res: Response, next: NextFunction
260
267
  case 'fastify':
261
268
  return {
262
269
  file: 'src/gurulu.ts',
263
- code: `// Gurulu.io Server Analytics — Fastify plugin
270
+ code: `// Gurulu.io Server Analytics — Fastify plugin (Phase 32 P32-A2 fix)
271
+ // npm install @gurulu/node
272
+ import { Gurulu } from '@gurulu/node';
264
273
  import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
265
274
 
266
- const SITE_ID = '${siteId}';
267
- const TOKEN = '${token}';
275
+ export const gurulu = new Gurulu({
276
+ siteId: '${siteId}',
277
+ token: '${token}',
278
+ endpoint: 'https://gurulu.io',
279
+ });
268
280
 
269
281
  export async function guruluPlugin(fastify: FastifyInstance) {
270
282
  fastify.addHook('onRequest', async (req: FastifyRequest, _reply: FastifyReply) => {
271
- fetch('https://ingest.gurulu.io/api/events', {
272
- method: 'POST',
273
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
274
- body: JSON.stringify({
275
- site_id: SITE_ID,
276
- event: 'pageview',
283
+ const anonymousId = (req.headers['x-gurulu-anonymous-id'] as string) || undefined;
284
+ gurulu.track({
285
+ eventName: 'pageview',
286
+ anonymousId,
287
+ properties: {
277
288
  url: req.url,
278
289
  referrer: (req.headers.referer as string) || '',
279
290
  user_agent: (req.headers['user-agent'] as string) || '',
280
- ip: req.ip,
281
- timestamp: new Date().toISOString(),
282
- }),
291
+ },
283
292
  }).catch(() => {});
284
293
  });
285
294
  }`,
@@ -288,24 +297,27 @@ export async function guruluPlugin(fastify: FastifyInstance) {
288
297
  case 'hono':
289
298
  return {
290
299
  file: 'src/gurulu.ts',
291
- code: `// Gurulu.io Server Analytics — Hono middleware
300
+ code: `// Gurulu.io Server Analytics — Hono middleware (Phase 32 P32-A2 fix)
301
+ // npm install @gurulu/node
302
+ import { Gurulu } from '@gurulu/node';
292
303
  import type { MiddlewareHandler } from 'hono';
293
304
 
294
- const SITE_ID = '${siteId}';
295
- const TOKEN = '${token}';
305
+ export const gurulu = new Gurulu({
306
+ siteId: '${siteId}',
307
+ token: '${token}',
308
+ endpoint: 'https://gurulu.io',
309
+ });
296
310
 
297
311
  export const guruluMiddleware: MiddlewareHandler = async (c, next) => {
298
- fetch('https://ingest.gurulu.io/api/events', {
299
- method: 'POST',
300
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
301
- body: JSON.stringify({
302
- site_id: SITE_ID,
303
- event: 'pageview',
312
+ const anonymousId = c.req.header('x-gurulu-anonymous-id') || undefined;
313
+ gurulu.track({
314
+ eventName: 'pageview',
315
+ anonymousId,
316
+ properties: {
304
317
  url: c.req.url,
305
318
  referrer: c.req.header('referer') || '',
306
319
  user_agent: c.req.header('user-agent') || '',
307
- timestamp: new Date().toISOString(),
308
- }),
320
+ },
309
321
  }).catch(() => {});
310
322
  await next();
311
323
  };`,
@@ -314,28 +326,30 @@ export const guruluMiddleware: MiddlewareHandler = async (c, next) => {
314
326
  case 'nestjs':
315
327
  return {
316
328
  file: 'src/gurulu.middleware.ts',
317
- code: `// Gurulu.io Server Analytics Middleware
329
+ code: `// Gurulu.io Server Analytics Middleware (Phase 32 P32-A2 fix)
330
+ // npm install @gurulu/node
318
331
  import { Injectable, NestMiddleware } from '@nestjs/common';
319
332
  import { Request, Response, NextFunction } from 'express';
333
+ import { Gurulu } from '@gurulu/node';
320
334
 
321
- const SITE_ID = '${siteId}';
322
- const TOKEN = '${token}';
335
+ const gurulu = new Gurulu({
336
+ siteId: '${siteId}',
337
+ token: '${token}',
338
+ endpoint: 'https://gurulu.io',
339
+ });
323
340
 
324
341
  @Injectable()
325
342
  export class GuruluMiddleware implements NestMiddleware {
326
343
  use(req: Request, res: Response, next: NextFunction) {
327
- fetch('https://ingest.gurulu.io/api/events', {
328
- method: 'POST',
329
- headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
330
- body: JSON.stringify({
331
- site_id: SITE_ID,
332
- event: 'pageview',
344
+ const anonymousId = (req.headers['x-gurulu-anonymous-id'] as string) || undefined;
345
+ gurulu.track({
346
+ eventName: 'pageview',
347
+ anonymousId,
348
+ properties: {
333
349
  url: req.originalUrl,
334
350
  referrer: req.headers.referer || '',
335
351
  user_agent: req.headers['user-agent'] || '',
336
- ip: req.ip,
337
- timestamp: new Date().toISOString(),
338
- }),
352
+ },
339
353
  }).catch(() => {});
340
354
  next();
341
355
  }
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ const funnels_1 = require("./commands/funnels");
38
38
  const heatmap_1 = require("./commands/heatmap");
39
39
  // Gurulu Chat — NL → SQL analytics
40
40
  const chat_1 = require("./commands/chat");
41
+ // 2026-05-05 DF-5 — zero-friction lifecycle setup
42
+ const setup_1 = require("./commands/setup");
41
43
  // Error tracking — source map upload
42
44
  const sourcemap_1 = require("./commands/sourcemap");
43
45
  // Phase 21 — database connect
@@ -99,7 +101,7 @@ const secrets_1 = require("./commands/secrets");
99
101
  .option('key', { type: 'string', describe: 'Secret key (gsk_live_... or gsk_test_...) — legacy manual flow' })
100
102
  .option('email', { type: 'string', describe: 'Email address (legacy flow)' })
101
103
  .option('secret-key', { type: 'string', describe: 'Secret key (legacy flow alias for --key)' })
102
- .option('no-browser', { type: 'boolean', describe: 'Do not auto-open the browser' })
104
+ .option('browser', { type: 'boolean', default: true, describe: 'Auto-open the browser (use --no-browser to disable)' })
103
105
  .option('no-interactive', { type: 'boolean', describe: 'Non-interactive mode (legacy flow)' })
104
106
  .option('keychain', { type: 'boolean', describe: 'Store secret in macOS Keychain if available' })
105
107
  .option('api-base', { type: 'string', describe: 'Override API base URL' }), (args) => (0, auth_1.authCommand)({
@@ -110,7 +112,7 @@ const secrets_1 = require("./commands/secrets");
110
112
  noInteractive: args['no-interactive'],
111
113
  useKeychain: args.keychain,
112
114
  apiBase: args['api-base'],
113
- noBrowser: args['no-browser'],
115
+ noBrowser: args.browser === false ? true : undefined,
114
116
  }))
115
117
  .command('logout', 'Remove a stored profile', (y) => y
116
118
  .option('all', { type: 'boolean', describe: 'Remove all profiles' })
@@ -619,24 +621,45 @@ const secrets_1 = require("./commands/secrets");
619
621
  format: args.format,
620
622
  json: args.json,
621
623
  profile: args.profile,
624
+ }))
625
+ // ── 2026-05-05 DF-5 — zero-friction lifecycle setup ──────────────────
626
+ .command('setup', 'Apply a vertical lifecycle taxonomy (events + funnels + goals) in one shot', (y) => y
627
+ .option('vertical', {
628
+ type: 'string',
629
+ describe: 'saas | ecommerce | marketplace | fintech | healthcare | education | media | igaming | generic',
630
+ })
631
+ .option('site', { type: 'string', describe: 'Site ID' })
632
+ .option('include-funnels', { type: 'boolean', default: true, describe: 'Also create funnels' })
633
+ .option('include-goals', { type: 'boolean', default: true, describe: 'Also create goals' })
634
+ .option('dry-run', { type: 'boolean', describe: 'Preview without writing' })
635
+ .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, setup_1.setupCommand)({
636
+ vertical: args.vertical,
637
+ site: args.site,
638
+ includeFunnels: args['include-funnels'],
639
+ includeGoals: args['include-goals'],
640
+ dryRun: args['dry-run'],
641
+ json: args.json,
642
+ profile: args.profile,
622
643
  }))
623
644
  // ── Gurulu Chat — NL → SQL analytics ─────────────────────────────────
624
645
  .command('chat [question]', 'Ask analytics questions in natural language (NL → SQL)', (y) => y
625
646
  .positional('question', { type: 'string', describe: 'Question to ask (omit for REPL mode)' })
626
647
  .option('json', { type: 'boolean', describe: 'Machine-readable JSON output' })
627
648
  .option('show-sql', { type: 'boolean', describe: 'Also print the generated SQL' })
628
- .option('context', { type: 'string', describe: 'Additional context for the query' }), (args) => (0, chat_1.chatCommand)({
649
+ .option('context', { type: 'string', describe: 'Additional context for the query' })
650
+ .option('site', { type: 'string', describe: 'Site ID (overrides profile default)' }), (args) => (0, chat_1.chatCommand)({
629
651
  question: args.question,
630
652
  json: args.json,
631
653
  showSql: args['show-sql'],
632
654
  context: args.context,
633
655
  profile: args.profile,
656
+ site: args.site,
634
657
  }))
635
658
  // ── Error tracking — source map upload ────────────────────────────────
636
659
  .command('sourcemap <action>', 'Upload source maps for error deobfuscation (upload). Supports web/server JS .map and native dSYM/ProGuard.', (y) => y
637
660
  .positional('action', { type: 'string', describe: 'upload' })
638
- .option('release', { type: 'string', describe: 'Release version (e.g. v1.0.0). Web/server only — alias of --version.' })
639
- .option('version', { type: 'string', describe: 'App version (e.g. 1.4.2). Required for native uploads.' })
661
+ .option('release', { type: 'string', describe: 'Release version (e.g. v1.0.0). Web/server only — alias of --app-version.' })
662
+ .option('app-version', { type: 'string', describe: 'App version (e.g. 1.4.2). Required for native uploads.' })
640
663
  .option('dir', { type: 'string', describe: 'Directory containing .map files (web/server)' })
641
664
  .option('file', { type: 'string', describe: 'Path to dSYM zip (iOS) or mapping.txt (Android)' })
642
665
  .option('bundle-id', { type: 'string', describe: 'iOS bundleId or Android applicationId (native only)' })
@@ -645,7 +668,7 @@ const secrets_1 = require("./commands/secrets");
645
668
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, sourcemap_1.sourcemapCommand)({
646
669
  action: args.action,
647
670
  release: args.release,
648
- version: args.version,
671
+ version: args['app-version'],
649
672
  dir: args.dir,
650
673
  file: args.file,
651
674
  bundleId: args['bundle-id'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gurulu/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"