@gurulu/cli 0.4.5 → 0.4.6

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
  }
@@ -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
  }
@@ -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
  }
package/dist/index.js CHANGED
@@ -99,7 +99,7 @@ const secrets_1 = require("./commands/secrets");
99
99
  .option('key', { type: 'string', describe: 'Secret key (gsk_live_... or gsk_test_...) — legacy manual flow' })
100
100
  .option('email', { type: 'string', describe: 'Email address (legacy flow)' })
101
101
  .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' })
102
+ .option('browser', { type: 'boolean', default: true, describe: 'Auto-open the browser (use --no-browser to disable)' })
103
103
  .option('no-interactive', { type: 'boolean', describe: 'Non-interactive mode (legacy flow)' })
104
104
  .option('keychain', { type: 'boolean', describe: 'Store secret in macOS Keychain if available' })
105
105
  .option('api-base', { type: 'string', describe: 'Override API base URL' }), (args) => (0, auth_1.authCommand)({
@@ -110,7 +110,7 @@ const secrets_1 = require("./commands/secrets");
110
110
  noInteractive: args['no-interactive'],
111
111
  useKeychain: args.keychain,
112
112
  apiBase: args['api-base'],
113
- noBrowser: args['no-browser'],
113
+ noBrowser: args.browser === false ? true : undefined,
114
114
  }))
115
115
  .command('logout', 'Remove a stored profile', (y) => y
116
116
  .option('all', { type: 'boolean', describe: 'Remove all profiles' })
@@ -635,8 +635,8 @@ const secrets_1 = require("./commands/secrets");
635
635
  // ── Error tracking — source map upload ────────────────────────────────
636
636
  .command('sourcemap <action>', 'Upload source maps for error deobfuscation (upload). Supports web/server JS .map and native dSYM/ProGuard.', (y) => y
637
637
  .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.' })
638
+ .option('release', { type: 'string', describe: 'Release version (e.g. v1.0.0). Web/server only — alias of --app-version.' })
639
+ .option('app-version', { type: 'string', describe: 'App version (e.g. 1.4.2). Required for native uploads.' })
640
640
  .option('dir', { type: 'string', describe: 'Directory containing .map files (web/server)' })
641
641
  .option('file', { type: 'string', describe: 'Path to dSYM zip (iOS) or mapping.txt (Android)' })
642
642
  .option('bundle-id', { type: 'string', describe: 'iOS bundleId or Android applicationId (native only)' })
@@ -645,7 +645,7 @@ const secrets_1 = require("./commands/secrets");
645
645
  .option('json', { type: 'boolean', describe: 'JSON output' }), (args) => (0, sourcemap_1.sourcemapCommand)({
646
646
  action: args.action,
647
647
  release: args.release,
648
- version: args.version,
648
+ version: args['app-version'],
649
649
  dir: args.dir,
650
650
  file: args.file,
651
651
  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.6",
4
4
  "description": "Gurulu.io CLI — setup analytics in seconds",
5
5
  "bin": {
6
6
  "gurulu": "bin/gurulu.js"