@gurulu/cli 0.4.2 → 0.4.4

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.
@@ -48,18 +48,24 @@ var __importStar = (this && this.__importStar) || (function () {
48
48
  };
49
49
  })();
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.resolveWorkspaceRoot = resolveWorkspaceRoot;
51
52
  exports.resolveScriptsDir = resolveScriptsDir;
52
53
  exports.detectPackageManager = detectPackageManager;
53
54
  exports.packageInstallArgs = packageInstallArgs;
55
+ exports.parseEnvFile = parseEnvFile;
54
56
  exports.mergeEnvFile = mergeEnvFile;
55
57
  exports.createDefaultDeps = createDefaultDeps;
56
58
  exports.runInstallFlow = runInstallFlow;
59
+ exports.isTelemetryDisabled = isTelemetryDisabled;
60
+ exports.maybePromptTelemetryConsent = maybePromptTelemetryConsent;
57
61
  exports.repoHashOf = repoHashOf;
58
62
  exports.pickSite = pickSite;
59
63
  exports.emitInstallTelemetry = emitInstallTelemetry;
64
+ exports.detectCiEnv = detectCiEnv;
60
65
  exports.runAuthenticatedInstallFlow = runAuthenticatedInstallFlow;
61
66
  exports.installCommand = installCommand;
62
67
  const fs = __importStar(require("fs"));
68
+ const os = __importStar(require("os"));
63
69
  const path = __importStar(require("path"));
64
70
  const crypto = __importStar(require("crypto"));
65
71
  const child_process_1 = require("child_process");
@@ -68,6 +74,159 @@ const config_1 = require("../config");
68
74
  const api_client_1 = require("../api-client");
69
75
  const install_intent_proposal_1 = require("../install-intent-proposal");
70
76
  // ---------------------------------------------------------------------------
77
+ // Sprint E1.5 — Workspace resolution.
78
+ //
79
+ // Many fresh repos are npm/pnpm/yarn workspaces (Turborepo, Nx, Bun, etc).
80
+ // Running `gurulu install` from the repo root is ambiguous: should we patch
81
+ // the `apps/web/` Next.js app, the `apps/admin/` dashboard, or one of the
82
+ // `packages/*` libraries? When package.json declares >=2 workspace globs we
83
+ // either (a) honour `--workspace=<path>` if set, or (b) prompt the user
84
+ // interactively. The resolved path becomes `repoRoot` for the rest of the
85
+ // install flow.
86
+ // ---------------------------------------------------------------------------
87
+ function walkForPackageJson(root, depth, out) {
88
+ // FA-1 P1-7 — recursive walker for `**/*` and `apps/**` style globs used by
89
+ // Turborepo and Yarn berry monorepos. We cap depth to avoid descending into
90
+ // node_modules / .git / huge build trees, and we only collect directories
91
+ // that actually contain a package.json.
92
+ if (depth < 0)
93
+ return;
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(root, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ for (const e of entries) {
102
+ if (!e.isDirectory())
103
+ continue;
104
+ if (e.name === 'node_modules' || e.name === '.git' || e.name.startsWith('.'))
105
+ continue;
106
+ const abs = path.join(root, e.name);
107
+ if (fs.existsSync(path.join(abs, 'package.json')))
108
+ out.push(abs);
109
+ walkForPackageJson(abs, depth - 1, out);
110
+ }
111
+ }
112
+ function expandWorkspaceGlobs(repoRoot, globs) {
113
+ // FA-1 P1-7 — workspace glob support now covers:
114
+ // - bare `dir` (single workspace)
115
+ // - `dir/*` (immediate children — npm/pnpm/yarn classic)
116
+ // - `dir/**`, `dir/**/*` (Turborepo / Yarn berry recursive)
117
+ // - `!packages/excluded` (negation filter, applied after expansion)
118
+ // - `workspace:*` protocol (Yarn berry — skipped, not a glob)
119
+ const positives = [];
120
+ const negatives = [];
121
+ for (const raw of globs) {
122
+ if (!raw || typeof raw !== 'string')
123
+ continue;
124
+ // Yarn berry sometimes lists `workspace:*` protocol entries — skip; they
125
+ // are dependency declarations, not workspace path globs.
126
+ if (raw.startsWith('workspace:'))
127
+ continue;
128
+ if (raw.startsWith('!')) {
129
+ negatives.push(raw.slice(1));
130
+ }
131
+ else {
132
+ positives.push(raw);
133
+ }
134
+ }
135
+ const collected = new Set();
136
+ for (const g of positives) {
137
+ // `dir/**`, `dir/**/*` — recursive walk up to 4 levels deep.
138
+ if (g.endsWith('/**') || g.endsWith('/**/*') || g === '**' || g === '**/*') {
139
+ const root = g === '**' || g === '**/*'
140
+ ? repoRoot
141
+ : path.join(repoRoot, g.replace(/\/\*\*(\/\*)?$/, ''));
142
+ if (!fs.existsSync(root))
143
+ continue;
144
+ const acc = [];
145
+ walkForPackageJson(root, 4, acc);
146
+ for (const a of acc)
147
+ collected.add(a);
148
+ continue;
149
+ }
150
+ if (g.endsWith('/*')) {
151
+ const parent = path.join(repoRoot, g.slice(0, -2));
152
+ if (!fs.existsSync(parent))
153
+ continue;
154
+ try {
155
+ for (const entry of fs.readdirSync(parent)) {
156
+ const abs = path.join(parent, entry);
157
+ if (fs.statSync(abs).isDirectory() &&
158
+ fs.existsSync(path.join(abs, 'package.json'))) {
159
+ collected.add(abs);
160
+ }
161
+ }
162
+ }
163
+ catch {
164
+ /* ignore */
165
+ }
166
+ continue;
167
+ }
168
+ // Bare directory.
169
+ const abs = path.join(repoRoot, g);
170
+ if (fs.existsSync(abs) && fs.existsSync(path.join(abs, 'package.json'))) {
171
+ collected.add(abs);
172
+ }
173
+ }
174
+ // Apply negations as simple suffix-match against the relative path.
175
+ if (negatives.length > 0) {
176
+ for (const dir of Array.from(collected)) {
177
+ const rel = path.relative(repoRoot, dir);
178
+ for (const n of negatives) {
179
+ const negResolved = n.replace(/\/\*+$/, '');
180
+ if (rel === negResolved || rel.startsWith(negResolved + path.sep)) {
181
+ collected.delete(dir);
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ }
187
+ return Array.from(collected);
188
+ }
189
+ async function resolveWorkspaceRoot(repoRoot, args, deps) {
190
+ const pkgPath = path.join(repoRoot, 'package.json');
191
+ if (!fs.existsSync(pkgPath))
192
+ return repoRoot;
193
+ let pkg;
194
+ try {
195
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
196
+ }
197
+ catch {
198
+ return repoRoot;
199
+ }
200
+ let globs = null;
201
+ if (Array.isArray(pkg.workspaces))
202
+ globs = pkg.workspaces;
203
+ else if (pkg.workspaces && Array.isArray(pkg.workspaces.packages))
204
+ globs = pkg.workspaces.packages;
205
+ if (!globs || globs.length === 0)
206
+ return repoRoot;
207
+ const candidates = expandWorkspaceGlobs(repoRoot, globs);
208
+ if (candidates.length <= 1)
209
+ return repoRoot;
210
+ if (args.workspace) {
211
+ const target = path.resolve(repoRoot, args.workspace);
212
+ if (candidates.some((c) => path.resolve(c) === target))
213
+ return target;
214
+ log(deps, 'warn', `--workspace=${args.workspace} not found among workspace packages.`);
215
+ }
216
+ if (args.yes) {
217
+ log(deps, 'warn', `Multiple workspaces detected (${candidates.length}); pass --workspace=<path> next time.`);
218
+ return repoRoot;
219
+ }
220
+ log(deps, 'info', 'Multiple workspaces detected:');
221
+ candidates.forEach((c, i) => log(deps, 'info', ` [${i + 1}] ${path.relative(repoRoot, c)}`));
222
+ const answer = (await deps.prompt(' Select workspace [1]: ')).trim() || '1';
223
+ const idx = parseInt(answer, 10) - 1;
224
+ if (Number.isFinite(idx) && idx >= 0 && idx < candidates.length) {
225
+ return candidates[idx];
226
+ }
227
+ return repoRoot;
228
+ }
229
+ // ---------------------------------------------------------------------------
71
230
  // Script resolution
72
231
  // ---------------------------------------------------------------------------
73
232
  /**
@@ -95,6 +254,14 @@ function resolveScriptsDir(startDir = __dirname) {
95
254
  // Package-manager detection
96
255
  // ---------------------------------------------------------------------------
97
256
  function detectPackageManager(repoRoot) {
257
+ // FA-1 P1-6 — Bun support. Bun ships either `bun.lockb` (binary) or
258
+ // `bun.lock` (text, newer Bun releases). Detect both before falling back
259
+ // to pnpm/yarn/npm so monorepos using Bun don't get installed with the
260
+ // wrong package manager.
261
+ if (fs.existsSync(path.join(repoRoot, 'bun.lockb')) ||
262
+ fs.existsSync(path.join(repoRoot, 'bun.lock'))) {
263
+ return 'bun';
264
+ }
98
265
  if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml')))
99
266
  return 'pnpm';
100
267
  if (fs.existsSync(path.join(repoRoot, 'yarn.lock')))
@@ -106,8 +273,94 @@ function packageInstallArgs(pm) {
106
273
  return ['add', '@gurulu/web'];
107
274
  if (pm === 'yarn')
108
275
  return ['add', '@gurulu/web'];
276
+ if (pm === 'bun')
277
+ return ['add', '@gurulu/web'];
109
278
  return ['install', '@gurulu/web'];
110
279
  }
280
+ // FA-1 P1-8 — full dotenv-compatible parser. The previous one-line
281
+ // `KEY=value` split mis-handled (a) quoted values with `=` inside,
282
+ // (b) multi-line values inside double-quotes (escape `\n`), and (c) the
283
+ // `export KEY=value` form some teams use. This parser walks the file
284
+ // character-by-character, tracking whether we're inside a quoted value, so
285
+ // the existing-keys set actually contains every declared key — preventing
286
+ // duplicate writes that would override the user's values.
287
+ function parseEnvFile(text) {
288
+ const keys = new Set();
289
+ let i = 0;
290
+ const n = text.length;
291
+ while (i < n) {
292
+ // Skip leading whitespace + newlines.
293
+ while (i < n && (text[i] === ' ' || text[i] === '\t' || text[i] === '\r' || text[i] === '\n')) {
294
+ i++;
295
+ }
296
+ if (i >= n)
297
+ break;
298
+ // Comment line.
299
+ if (text[i] === '#') {
300
+ while (i < n && text[i] !== '\n')
301
+ i++;
302
+ continue;
303
+ }
304
+ // Optional `export ` prefix.
305
+ if (text.startsWith('export ', i) || text.startsWith('export\t', i)) {
306
+ i += 7;
307
+ while (i < n && (text[i] === ' ' || text[i] === '\t'))
308
+ i++;
309
+ }
310
+ // Key.
311
+ const keyStart = i;
312
+ while (i < n && text[i] !== '=' && text[i] !== '\n' && text[i] !== '#')
313
+ i++;
314
+ const key = text.slice(keyStart, i).trim();
315
+ if (text[i] !== '=') {
316
+ // No `=` on this line — skip to next newline.
317
+ while (i < n && text[i] !== '\n')
318
+ i++;
319
+ continue;
320
+ }
321
+ if (key && /^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
322
+ keys.add(key);
323
+ }
324
+ i++; // consume '='
325
+ // Value — handles "..." (multi-line, escape-aware), '...', and bare.
326
+ if (text[i] === '"') {
327
+ i++;
328
+ while (i < n) {
329
+ if (text[i] === '\\' && i + 1 < n) {
330
+ i += 2;
331
+ continue;
332
+ }
333
+ if (text[i] === '"') {
334
+ i++;
335
+ break;
336
+ }
337
+ i++;
338
+ }
339
+ }
340
+ else if (text[i] === "'") {
341
+ i++;
342
+ while (i < n && text[i] !== "'")
343
+ i++;
344
+ if (i < n)
345
+ i++;
346
+ }
347
+ else if (text[i] === '`') {
348
+ i++;
349
+ while (i < n && text[i] !== '`')
350
+ i++;
351
+ if (i < n)
352
+ i++;
353
+ }
354
+ else {
355
+ while (i < n && text[i] !== '\n' && text[i] !== '#')
356
+ i++;
357
+ }
358
+ // Skip rest of the line (trailing comment / whitespace).
359
+ while (i < n && text[i] !== '\n')
360
+ i++;
361
+ }
362
+ return keys;
363
+ }
111
364
  function mergeEnvFile(repoRoot, framework, vars) {
112
365
  const isNext = framework.startsWith('nextjs') || framework === 'nextjs';
113
366
  const filename = isNext ? '.env.local' : '.env';
@@ -116,11 +369,9 @@ function mergeEnvFile(repoRoot, framework, vars) {
116
369
  if (fs.existsSync(filePath)) {
117
370
  existing = fs.readFileSync(filePath, 'utf8');
118
371
  }
119
- const existingKeys = new Set(existing
120
- .split('\n')
121
- .map((l) => l.trim())
122
- .filter((l) => l && !l.startsWith('#'))
123
- .map((l) => l.split('=')[0].trim()));
372
+ // FA-1 P1-8 proper quoted/multiline-aware parse so we don't shadow keys
373
+ // the user already defined inside `KEY="multi\nline"` blocks.
374
+ const existingKeys = parseEnvFile(existing);
124
375
  const added = [];
125
376
  const skipped = [];
126
377
  const linesToAppend = [];
@@ -129,7 +380,13 @@ function mergeEnvFile(repoRoot, framework, vars) {
129
380
  skipped.push(key);
130
381
  continue;
131
382
  }
132
- linesToAppend.push(`${key}=${value}`);
383
+ // Quote values that contain whitespace, `=`, `#`, or quotes themselves so
384
+ // they round-trip through any standard dotenv parser.
385
+ const needsQuote = /[\s="#\\]/.test(value);
386
+ const serialized = needsQuote
387
+ ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
388
+ : value;
389
+ linesToAppend.push(`${key}=${serialized}`);
133
390
  added.push(key);
134
391
  }
135
392
  if (linesToAppend.length > 0) {
@@ -140,6 +397,38 @@ function mergeEnvFile(repoRoot, framework, vars) {
140
397
  return { file: filename, added, skipped };
141
398
  }
142
399
  // ---------------------------------------------------------------------------
400
+ // Sprint E2.2 — Fetch canonical install prompt from server endpoint.
401
+ // ---------------------------------------------------------------------------
402
+ async function fetchInstallPrompt(opts) {
403
+ const fallback = `I have Gurulu analytics installed. Site ID: ${opts.siteId}\n` +
404
+ `Analyze my codebase and add gurulu.track() calls to\n` +
405
+ `important user actions: signups, purchases, form submits,\n` +
406
+ `button clicks, and key conversions. Use the Gurulu MCP\n` +
407
+ `server (@gurulu/mcp-server) for live event verification.\n` +
408
+ `Docs: https://gurulu.io/docs/quick-start`;
409
+ if (!opts.authToken || !opts.siteId)
410
+ return fallback;
411
+ try {
412
+ const url = new URL('/api/cli/install/prompt', opts.ingestUrl);
413
+ url.searchParams.set('siteId', opts.siteId);
414
+ if (opts.framework)
415
+ url.searchParams.set('framework', opts.framework);
416
+ const res = await globalThis.fetch(url.toString(), {
417
+ headers: { authorization: `Bearer ${opts.authToken}` },
418
+ });
419
+ if (!res.ok)
420
+ return fallback;
421
+ const body = await res.json();
422
+ if (body && typeof body.prompt === 'string' && body.prompt.length > 0) {
423
+ return body.prompt;
424
+ }
425
+ return fallback;
426
+ }
427
+ catch {
428
+ return fallback;
429
+ }
430
+ }
431
+ // ---------------------------------------------------------------------------
143
432
  // Default dependency implementations (real spawn + real fetch)
144
433
  // ---------------------------------------------------------------------------
145
434
  function createDefaultDeps(scriptsDir) {
@@ -251,7 +540,11 @@ function getEnvPrefix(framework) {
251
540
  return ''; // Express, Fastify, NestJS don't need prefix
252
541
  }
253
542
  async function runInstallFlow(args, deps, scriptsDir) {
254
- const repoRoot = path.resolve(args.path || process.cwd());
543
+ const initialRoot = path.resolve(args.path || process.cwd());
544
+ // Sprint E1.5 — workspace resolution. When the project is a multi-package
545
+ // workspace, narrow `repoRoot` to the chosen workspace package so scan,
546
+ // patches, .env merge, and npm install all target the right place.
547
+ const repoRoot = await resolveWorkspaceRoot(initialRoot, args, deps);
255
548
  const summary = {
256
549
  scan: null,
257
550
  framework: null,
@@ -339,6 +632,16 @@ async function runInstallFlow(args, deps, scriptsDir) {
339
632
  planArgs.push('--intent-result', args.intentResultPath);
340
633
  }
341
634
  }
635
+ // FA-1 P0-2 — forward self-hosted overrides so the dry-run plan, the
636
+ // applied tracker tag, and the LLM endpoint base all agree. Without this
637
+ // self-hosted installs render `<script src="https://gurulu.io/t.js">` even
638
+ // when the user pointed `--ingest-url` at their own host.
639
+ if (args.ingestUrl) {
640
+ planArgs.push('--ingest-url', args.ingestUrl);
641
+ }
642
+ if (args.scriptSrc) {
643
+ planArgs.push('--script-src', args.scriptSrc);
644
+ }
342
645
  const planRes = await deps.runNode(planArgs);
343
646
  summary.planDiff = planRes.stdout;
344
647
  if (planRes.code !== 0) {
@@ -389,13 +692,52 @@ async function runInstallFlow(args, deps, scriptsDir) {
389
692
  applyArgs.push('--intent-result', args.intentResultPath);
390
693
  }
391
694
  }
695
+ // FA-1 P0-2 — also forward to --apply so the file actually written to
696
+ // disk uses the correct tracker URL.
697
+ if (args.ingestUrl) {
698
+ applyArgs.push('--ingest-url', args.ingestUrl);
699
+ }
700
+ if (args.scriptSrc) {
701
+ applyArgs.push('--script-src', args.scriptSrc);
702
+ }
392
703
  const applyRes = await deps.runNode(applyArgs);
704
+ // Sprint E1.1 — exit code 5 means the agentic-install script auto-rolled
705
+ // back due to an auto-instrument failure. Mark the summary as rolled back
706
+ // and skip the npm install / .env merge / ingest ping blocks so the user
707
+ // isn't left with a half-installed setup.
708
+ if (applyRes.code === 5) {
709
+ summary.rolledBack = true;
710
+ summary.partiallyInstalled = false;
711
+ log(deps, 'warn', '⚠ Install rolled back (auto-instrument failed; script tag reverted).');
712
+ return summary;
713
+ }
393
714
  if (applyRes.code !== 0) {
394
715
  const msg = `Apply failed (exit ${applyRes.code}): ${applyRes.stderr.trim()}`;
395
716
  summary.errors.push(msg);
396
717
  log(deps, 'error', msg);
397
718
  return summary;
398
719
  }
720
+ // Sprint E1.7 — record components that were successfully installed for
721
+ // partial-install diagnostics in the summary.
722
+ // CLI@0.4.4 — re-run idempotency: dedupe via Set so a second `gurulu install`
723
+ // pass on the same project does not double-list components. Component IDs
724
+ // hash siteId + framework + path so two different sites in the same repo
725
+ // remain distinct.
726
+ const componentScope = `${args.site || args.siteId || 'unknown'}::${args.framework || 'auto'}::${args.path || process.cwd()}`;
727
+ const existingComponents = new Set(summary.installedComponents || []);
728
+ const addComponent = (id) => {
729
+ const key = `${id}@${componentScope}`;
730
+ if (existingComponents.has(key)) {
731
+ log(deps, 'info', `Skipping ${id} — already installed for this scope`);
732
+ return;
733
+ }
734
+ existingComponents.add(key);
735
+ };
736
+ addComponent('script-tag');
737
+ addComponent('patch-log');
738
+ if (args.autoInstrument)
739
+ addComponent('auto-instrument');
740
+ summary.installedComponents = Array.from(existingComponents);
399
741
  // Parse the machine-readable auto-instrument result line if present.
400
742
  if (args.autoInstrument) {
401
743
  const lines = (applyRes.stdout || '').split('\n');
@@ -427,6 +769,29 @@ async function runInstallFlow(args, deps, scriptsDir) {
427
769
  }
428
770
  }
429
771
  log(deps, 'success', 'Patches applied.');
772
+ // Sprint E5.2 — wire a `postbuild` script for Next.js projects so the
773
+ // CLI's sourcemap uploader runs automatically after every `next build`.
774
+ // We only touch package.json when the framework is Next.js AND the user
775
+ // hasn't already defined a `postbuild` (idempotent).
776
+ if (detectedFw && detectedFw.startsWith('nextjs')) {
777
+ try {
778
+ const pkgPath = path.join(repoRoot, 'package.json');
779
+ if (fs.existsSync(pkgPath)) {
780
+ const raw = fs.readFileSync(pkgPath, 'utf8');
781
+ const pkgJson = JSON.parse(raw);
782
+ pkgJson.scripts = pkgJson.scripts || {};
783
+ if (!pkgJson.scripts.postbuild) {
784
+ pkgJson.scripts.postbuild =
785
+ 'gurulu sourcemap upload --release ${npm_package_version} --dir .next/static/chunks';
786
+ fs.writeFileSync(pkgPath, JSON.stringify(pkgJson, null, 2) + '\n');
787
+ log(deps, 'info', 'Added `postbuild` script for sourcemap upload.');
788
+ }
789
+ }
790
+ }
791
+ catch (err) {
792
+ log(deps, 'warn', `Could not add postbuild script: ${err.message}`);
793
+ }
794
+ }
430
795
  }
431
796
  else {
432
797
  if (args.autoInstrument) {
@@ -575,13 +940,21 @@ async function runInstallFlow(args, deps, scriptsDir) {
575
940
  console.log('');
576
941
  log(deps, 'info', ` ${(0, ui_1.dim)('Or paste this prompt into your AI assistant:')}`);
577
942
  console.log('');
943
+ // Sprint E2.2 — fetch the canonical install prompt from the server so
944
+ // edits land in `src/lib/cli/install-prompt.ts` (the single source of
945
+ // truth) instead of being duplicated in three places. We fall back to a
946
+ // tiny inline body when the endpoint is unreachable or unauthenticated
947
+ // so legacy installs still see something useful.
948
+ const promptText = await fetchInstallPrompt({
949
+ siteId,
950
+ ingestUrl: args.ingestUrl || process.env.GURULU_INGEST_URL || 'https://gurulu.io',
951
+ authToken: args.authToken,
952
+ framework: summary.framework || undefined,
953
+ });
578
954
  console.log((0, ui_1.dim)(' ┌─────────────────────────────────────────────'));
579
- console.log((0, ui_1.dim)(' │ ') + 'I have Gurulu analytics installed. Site ID: ' + (0, ui_1.cyan)(siteId));
580
- console.log((0, ui_1.dim)(' │ ') + 'Analyze my codebase and add gurulu.track() calls to');
581
- console.log((0, ui_1.dim)(' │ ') + 'important user actions: signups, purchases, form submits,');
582
- console.log((0, ui_1.dim)(' │ ') + 'button clicks, and key conversions. Use the Gurulu MCP');
583
- console.log((0, ui_1.dim)(' │ ') + 'server (@gurulu/mcp-server) for live event verification.');
584
- console.log((0, ui_1.dim)(' │ ') + 'Docs: https://gurulu.io/docs/quick-start');
955
+ for (const line of promptText.split('\n')) {
956
+ console.log((0, ui_1.dim)(' │ ') + line);
957
+ }
585
958
  console.log((0, ui_1.dim)(' └─────────────────────────────────────────────'));
586
959
  console.log('');
587
960
  // Goals & funnels management
@@ -613,7 +986,91 @@ async function runInstallFlow(args, deps, scriptsDir) {
613
986
  }
614
987
  return summary;
615
988
  }
989
+ // ---------------------------------------------------------------------------
990
+ // FA-1 P0-5 — Telemetry consent gate (GDPR).
991
+ //
992
+ // `emitInstallTelemetry` previously fired on every install with a repo hash,
993
+ // framework, and CLI version — no consent recorded. We now:
994
+ // 1. Honor opt-out via env (`GURULU_TELEMETRY=off|0`, `DO_NOT_TRACK=1`),
995
+ // CLI flag (`--no-telemetry`), and persisted CLI config
996
+ // (`~/.gurulu/config.json` → `telemetry: false`).
997
+ // 2. Prompt for consent on first interactive install and persist the
998
+ // answer in the same config file.
999
+ // 3. Skip `repoHashOf` computation entirely when telemetry is disabled.
1000
+ // ---------------------------------------------------------------------------
1001
+ function cliConfigPath() {
1002
+ if (process.env.GURULU_CONFIG_HOME) {
1003
+ return path.join(process.env.GURULU_CONFIG_HOME, 'config.json');
1004
+ }
1005
+ return path.join(os.homedir(), '.gurulu', 'config.json');
1006
+ }
1007
+ function readCliConfig() {
1008
+ try {
1009
+ const p = cliConfigPath();
1010
+ if (!fs.existsSync(p))
1011
+ return {};
1012
+ const txt = fs.readFileSync(p, 'utf8');
1013
+ const parsed = JSON.parse(txt);
1014
+ return parsed && typeof parsed === 'object' ? parsed : {};
1015
+ }
1016
+ catch {
1017
+ return {};
1018
+ }
1019
+ }
1020
+ function writeCliConfig(patch) {
1021
+ try {
1022
+ const p = cliConfigPath();
1023
+ const dir = path.dirname(p);
1024
+ if (!fs.existsSync(dir))
1025
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
1026
+ const current = readCliConfig();
1027
+ const next = { ...current, ...patch };
1028
+ fs.writeFileSync(p, JSON.stringify(next, null, 2));
1029
+ try {
1030
+ fs.chmodSync(p, 0o600);
1031
+ }
1032
+ catch {
1033
+ /* non-unix */
1034
+ }
1035
+ }
1036
+ catch {
1037
+ /* best-effort persistence */
1038
+ }
1039
+ }
1040
+ function isTelemetryDisabled() {
1041
+ const env = (process.env.GURULU_TELEMETRY || '').toLowerCase();
1042
+ if (env === 'off' || env === '0' || env === 'false' || env === 'no')
1043
+ return true;
1044
+ if (process.env.DO_NOT_TRACK === '1')
1045
+ return true;
1046
+ if (process.argv.includes('--no-telemetry'))
1047
+ return true;
1048
+ const cfg = readCliConfig();
1049
+ if (cfg.telemetry === false)
1050
+ return true;
1051
+ return false;
1052
+ }
1053
+ /**
1054
+ * First-run consent prompt. Persists the decision so we never re-ask.
1055
+ * No-op when telemetry is already disabled, when a decision is already
1056
+ * recorded, or in non-interactive (`--yes` / `--no-interactive`) mode.
1057
+ */
1058
+ async function maybePromptTelemetryConsent(ask, args = {}) {
1059
+ if (isTelemetryDisabled())
1060
+ return;
1061
+ const cfg = readCliConfig();
1062
+ if (typeof cfg.telemetry === 'boolean')
1063
+ return; // already decided
1064
+ if (args.yes)
1065
+ return; // non-interactive: don't ask, default-on but unrecorded
1066
+ const answer = await ask(' Send anonymous install telemetry (framework, CLI version, repo hash) to improve Gurulu? [Y/n]: ');
1067
+ const decided = !answer || !/^n/i.test(answer.trim());
1068
+ writeCliConfig({ telemetry: decided, telemetry_decided_at: new Date().toISOString() });
1069
+ }
616
1070
  function repoHashOf(repoRoot, remoteUrl = null) {
1071
+ // Consent-gated: when telemetry is off, never compute the hash.
1072
+ if (isTelemetryDisabled())
1073
+ return '';
617
1074
  const h = crypto.createHash('sha256');
618
1075
  h.update(path.resolve(repoRoot));
619
1076
  if (remoteUrl)
@@ -630,6 +1087,12 @@ function pickSite(sites, target) {
630
1087
  }
631
1088
  /** Send a telemetry POST. Never throws — quota errors are surfaced via `ok`. */
632
1089
  async function emitInstallTelemetry(deps, payload) {
1090
+ // FA-1 P0-5 — GDPR consent gate. Skip the POST entirely when the user has
1091
+ // opted out via env/flag/config. Returns ok:true so callers don't surface
1092
+ // a fake failure for an intentional opt-out.
1093
+ if (isTelemetryDisabled()) {
1094
+ return { ok: true, quotaExceeded: false };
1095
+ }
633
1096
  try {
634
1097
  const res = await deps.postVerify({
635
1098
  source: 'cli',
@@ -643,7 +1106,48 @@ async function emitInstallTelemetry(deps, payload) {
643
1106
  return { ok: false, quotaExceeded: false };
644
1107
  }
645
1108
  }
1109
+ // FA-1 P0-1 — Detect popular CI environments. When a CI is detected and the
1110
+ // caller didn't already pass `--yes`, we force non-interactive mode so any
1111
+ // `prompt()` calls fall through to the documented default instead of blocking
1112
+ // on a closed stdin (which causes the entire install to hang in CI).
1113
+ const CI_ENV_VARS = [
1114
+ 'CI',
1115
+ 'VERCEL',
1116
+ 'GITHUB_ACTIONS',
1117
+ 'NETLIFY',
1118
+ 'GITLAB_CI',
1119
+ 'CIRCLECI',
1120
+ 'TRAVIS',
1121
+ 'BUILDKITE',
1122
+ ];
1123
+ function detectCiEnv(env = process.env) {
1124
+ return CI_ENV_VARS.some((v) => {
1125
+ const raw = env[v];
1126
+ if (!raw)
1127
+ return false;
1128
+ const lower = String(raw).toLowerCase();
1129
+ return lower === '1' || lower === 'true';
1130
+ });
1131
+ }
646
1132
  async function runAuthenticatedInstallFlow(args, authDeps, installDeps, scriptsDir) {
1133
+ // FA-1 P0-1 — CI environments do not have an interactive stdin. Without
1134
+ // this guard every `prompt()` (`Continue with install? [Y/n]`, workspace
1135
+ // picker, telemetry consent, auto-instrument confirm, etc.) blocks on EOF
1136
+ // and the CI job times out instead of failing fast. Default to `--yes` so
1137
+ // every prompt accepts the documented default and the flow can complete.
1138
+ if (!args.yes && detectCiEnv()) {
1139
+ installDeps.log?.info('[install] CI environment detected, defaulting to --yes');
1140
+ args = { ...args, yes: true };
1141
+ }
1142
+ // FA-1 P0-5 — telemetry consent prompt on first interactive install.
1143
+ // Persisted to ~/.gurulu/config.json so we never re-ask.
1144
+ try {
1145
+ const ask = authDeps.prompt || ((q) => installDeps.prompt(q));
1146
+ await maybePromptTelemetryConsent(ask, { yes: args.yes });
1147
+ }
1148
+ catch {
1149
+ /* never block install on consent prompt errors */
1150
+ }
647
1151
  // Site selection
648
1152
  let selected = pickSite(authDeps.sites, args.site);
649
1153
  if (!selected) {
@@ -726,7 +1230,13 @@ async function runAuthenticatedInstallFlow(args, authDeps, installDeps, scriptsD
726
1230
  installDeps.log?.warn(`Intent analyzer failed: ${err.message}`);
727
1231
  installDeps.log?.warn('⚠ Falling back to built-in generic event set.');
728
1232
  intentRecord.error = `analyze_failed:${err.message}`;
729
- intentRecord.analyzed = true; // Mark as analyzed so auto-instrument proceeds
1233
+ // Sprint E1.2 distinguish "analyzer ran" from "fallback was used".
1234
+ // `analyzed=false` so callers can detect that the LLM/heuristic
1235
+ // analyzer never produced a real result; `analyze_status='fallback'`
1236
+ // tells them the install still proceeded with a baked-in event set
1237
+ // instead of failing outright.
1238
+ intentRecord.analyzed = false;
1239
+ intentRecord.analyze_status = 'fallback';
730
1240
  // Client-side fallback: produce a minimal generic intent so
731
1241
  // auto-instrument and pre-seed still work even when the API is down.
732
1242
  intent = {
@@ -819,7 +1329,11 @@ async function runAuthenticatedInstallFlow(args, authDeps, installDeps, scriptsD
819
1329
  let autoInstrumentEnabled = !!args.autoInstrument;
820
1330
  let intentResultPath;
821
1331
  if (autoInstrumentEnabled) {
822
- if (!intentRecord.analyzed || !intentRecord.accepted || intentRecord.accepted.events === 0) {
1332
+ // Sprint E1.2 accept either `analyzed=true` OR a fallback intent so
1333
+ // auto-instrument still proceeds when the analyzer crashed and we fell
1334
+ // back to the built-in generic event set.
1335
+ const intentUsable = intentRecord.analyzed || intentRecord.analyze_status === 'fallback';
1336
+ if (!intentUsable || !intentRecord.accepted || intentRecord.accepted.events === 0) {
823
1337
  installDeps.log?.warn('Auto-instrument requested but no accepted events from intent discovery; disabling.');
824
1338
  autoInstrumentEnabled = false;
825
1339
  }
@@ -950,6 +1464,13 @@ async function installCommand(args) {
950
1464
  // verify as strictly opt-in: only `args.verify === true` runs it. We also
951
1465
  // patch the CLI option default in `src/index.ts` so the flag matches.
952
1466
  args = { ...args, verify: args.verify === true };
1467
+ // FA-1 P0-1 — top-level CI guard so the legacy unauthenticated path also
1468
+ // skips interactive prompts when run from CI. (The authenticated flow
1469
+ // re-checks inside `runAuthenticatedInstallFlow` for unit-test clarity.)
1470
+ if (!args.yes && detectCiEnv()) {
1471
+ console.log('[install] CI environment detected, defaulting to --yes');
1472
+ args = { ...args, yes: true };
1473
+ }
953
1474
  // Legacy unauthenticated path: --site-id passed explicitly and no active profile.
954
1475
  const profile = await (0, config_1.getActiveProfile)({ profile: args.profile });
955
1476
  const legacyMode = !!args.siteId && !profile;
@@ -1011,18 +1532,33 @@ async function installCommand(args) {
1011
1532
  },
1012
1533
  intent: {
1013
1534
  analyze: async (signals) => {
1014
- const res = await (0, api_client_1.cliApi)('/api/cli/install/analyze-intent', {
1015
- method: 'POST',
1016
- preloadedProfile: profile,
1017
- body: JSON.stringify({ signals }),
1018
- noExitOnError: true,
1019
- });
1020
- const text = await res.text();
1021
- const parsed = text ? JSON.parse(text) : {};
1022
- if (!res.ok) {
1023
- throw new Error((parsed && parsed.message) || `HTTP ${res.status}`);
1535
+ // FA-1 P0-3 bound the LLM round-trip. Without this guard a hung
1536
+ // upstream stalls the whole install (Sprint E1.2 fallback never
1537
+ // fires until the request finally errors out). 30s mirrors the
1538
+ // tryLlmExtraction timeout in scripts/gurulu-agentic-install.mjs.
1539
+ try {
1540
+ const res = await (0, api_client_1.cliApi)('/api/cli/install/analyze-intent', {
1541
+ method: 'POST',
1542
+ preloadedProfile: profile,
1543
+ body: JSON.stringify({ signals }),
1544
+ noExitOnError: true,
1545
+ signal: AbortSignal.timeout(30_000),
1546
+ });
1547
+ const text = await res.text();
1548
+ const parsed = text ? JSON.parse(text) : {};
1549
+ if (!res.ok) {
1550
+ throw new Error((parsed && parsed.message) || `HTTP ${res.status}`);
1551
+ }
1552
+ return parsed.intent;
1553
+ }
1554
+ catch (err) {
1555
+ const e = err;
1556
+ if (e && (e.name === 'AbortError' || e.name === 'TimeoutError')) {
1557
+ console.warn('[install] LLM analyze-intent timeout — falling back to heuristic intent');
1558
+ throw new Error('analyze-intent timeout');
1559
+ }
1560
+ throw err;
1024
1561
  }
1025
- return parsed.intent;
1026
1562
  },
1027
1563
  preSeed: async (body) => {
1028
1564
  const res = await (0, api_client_1.cliApi)('/api/cli/install/pre-seed', {