@gurulu/cli 0.4.7 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/LICENSE +92 -0
  2. package/README.md +35 -106
  3. package/dist/bin.d.ts +3 -0
  4. package/dist/bin.d.ts.map +1 -0
  5. package/dist/bin.js +25751 -0
  6. package/dist/commands/auth.d.ts +23 -20
  7. package/dist/commands/auth.d.ts.map +1 -0
  8. package/dist/commands/doctor.d.ts +20 -6
  9. package/dist/commands/doctor.d.ts.map +1 -0
  10. package/dist/commands/init.d.ts +33 -11
  11. package/dist/commands/init.d.ts.map +1 -0
  12. package/dist/commands/pull.d.ts +13 -0
  13. package/dist/commands/pull.d.ts.map +1 -0
  14. package/dist/commands/push.d.ts +40 -0
  15. package/dist/commands/push.d.ts.map +1 -0
  16. package/dist/commands/validate.d.ts +36 -0
  17. package/dist/commands/validate.d.ts.map +1 -0
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25326 -876
  21. package/dist/lib/api.d.ts +139 -0
  22. package/dist/lib/api.d.ts.map +1 -0
  23. package/dist/lib/codegen.d.ts +4 -0
  24. package/dist/lib/codegen.d.ts.map +1 -0
  25. package/dist/lib/config.d.ts +43 -0
  26. package/dist/lib/config.d.ts.map +1 -0
  27. package/dist/lib/detect.d.ts +27 -0
  28. package/dist/lib/detect.d.ts.map +1 -0
  29. package/dist/lib/detect.js +106 -0
  30. package/dist/lib/exec-install.d.ts +21 -0
  31. package/dist/lib/exec-install.d.ts.map +1 -0
  32. package/dist/lib/install-plan.d.ts +25 -0
  33. package/dist/lib/install-plan.d.ts.map +1 -0
  34. package/dist/lib/install-plan.js +161 -0
  35. package/package.json +51 -20
  36. package/bin/gurulu.js +0 -2
  37. package/dist/api-client.d.ts +0 -33
  38. package/dist/api-client.js +0 -175
  39. package/dist/commands/add-server.d.ts +0 -9
  40. package/dist/commands/add-server.js +0 -162
  41. package/dist/commands/alerts.d.ts +0 -27
  42. package/dist/commands/alerts.js +0 -309
  43. package/dist/commands/api-keys.d.ts +0 -20
  44. package/dist/commands/api-keys.js +0 -130
  45. package/dist/commands/attribution.d.ts +0 -22
  46. package/dist/commands/attribution.js +0 -111
  47. package/dist/commands/audiences.d.ts +0 -23
  48. package/dist/commands/audiences.js +0 -243
  49. package/dist/commands/audit.d.ts +0 -20
  50. package/dist/commands/audit.js +0 -130
  51. package/dist/commands/auth.js +0 -249
  52. package/dist/commands/chat.d.ts +0 -19
  53. package/dist/commands/chat.js +0 -118
  54. package/dist/commands/config.d.ts +0 -10
  55. package/dist/commands/config.js +0 -92
  56. package/dist/commands/consent.d.ts +0 -27
  57. package/dist/commands/consent.js +0 -233
  58. package/dist/commands/conversion-paths.d.ts +0 -19
  59. package/dist/commands/conversion-paths.js +0 -55
  60. package/dist/commands/db.d.ts +0 -25
  61. package/dist/commands/db.js +0 -330
  62. package/dist/commands/destinations.d.ts +0 -20
  63. package/dist/commands/destinations.js +0 -191
  64. package/dist/commands/doctor.js +0 -360
  65. package/dist/commands/errors.d.ts +0 -27
  66. package/dist/commands/errors.js +0 -121
  67. package/dist/commands/events.d.ts +0 -33
  68. package/dist/commands/events.js +0 -371
  69. package/dist/commands/experiments.d.ts +0 -22
  70. package/dist/commands/experiments.js +0 -264
  71. package/dist/commands/funnels.d.ts +0 -17
  72. package/dist/commands/funnels.js +0 -203
  73. package/dist/commands/goals.d.ts +0 -18
  74. package/dist/commands/goals.js +0 -214
  75. package/dist/commands/heatmap.d.ts +0 -27
  76. package/dist/commands/heatmap.js +0 -112
  77. package/dist/commands/identity.d.ts +0 -29
  78. package/dist/commands/identity.js +0 -328
  79. package/dist/commands/init.js +0 -215
  80. package/dist/commands/insights.d.ts +0 -10
  81. package/dist/commands/insights.js +0 -77
  82. package/dist/commands/install.d.ts +0 -259
  83. package/dist/commands/install.js +0 -1590
  84. package/dist/commands/login.d.ts +0 -20
  85. package/dist/commands/login.js +0 -170
  86. package/dist/commands/logout.d.ts +0 -10
  87. package/dist/commands/logout.js +0 -41
  88. package/dist/commands/playground.d.ts +0 -11
  89. package/dist/commands/playground.js +0 -47
  90. package/dist/commands/releases.d.ts +0 -17
  91. package/dist/commands/releases.js +0 -54
  92. package/dist/commands/replay.d.ts +0 -18
  93. package/dist/commands/replay.js +0 -64
  94. package/dist/commands/secrets.d.ts +0 -19
  95. package/dist/commands/secrets.js +0 -145
  96. package/dist/commands/setup.d.ts +0 -21
  97. package/dist/commands/setup.js +0 -67
  98. package/dist/commands/sites.d.ts +0 -18
  99. package/dist/commands/sites.js +0 -139
  100. package/dist/commands/skad.d.ts +0 -18
  101. package/dist/commands/skad.js +0 -53
  102. package/dist/commands/sourcemap.d.ts +0 -33
  103. package/dist/commands/sourcemap.js +0 -204
  104. package/dist/commands/status.d.ts +0 -7
  105. package/dist/commands/status.js +0 -136
  106. package/dist/commands/upgrade.d.ts +0 -21
  107. package/dist/commands/upgrade.js +0 -183
  108. package/dist/commands/warehouse.d.ts +0 -20
  109. package/dist/commands/warehouse.js +0 -65
  110. package/dist/commands/warehouses.d.ts +0 -17
  111. package/dist/commands/warehouses.js +0 -182
  112. package/dist/commands/watch.d.ts +0 -45
  113. package/dist/commands/watch.js +0 -258
  114. package/dist/commands/whoami.d.ts +0 -9
  115. package/dist/commands/whoami.js +0 -50
  116. package/dist/config.d.ts +0 -75
  117. package/dist/config.js +0 -329
  118. package/dist/frameworks/detect.d.ts +0 -8
  119. package/dist/frameworks/detect.js +0 -458
  120. package/dist/install-intent-proposal.d.ts +0 -99
  121. package/dist/install-intent-proposal.js +0 -202
  122. package/dist/utils/api.d.ts +0 -20
  123. package/dist/utils/api.js +0 -47
  124. package/dist/utils/config.d.ts +0 -13
  125. package/dist/utils/config.js +0 -30
  126. package/dist/utils/confirm.d.ts +0 -17
  127. package/dist/utils/confirm.js +0 -40
  128. package/dist/utils/dry-run.d.ts +0 -20
  129. package/dist/utils/dry-run.js +0 -67
  130. package/dist/utils/from-file.d.ts +0 -9
  131. package/dist/utils/from-file.js +0 -72
  132. package/dist/utils/redact.d.ts +0 -14
  133. package/dist/utils/redact.js +0 -48
  134. package/dist/utils/ui.d.ts +0 -14
  135. package/dist/utils/ui.js +0 -59
  136. package/scripts/.gitkeep +0 -0
  137. package/scripts/README-gurulu-agentic-install.md +0 -114
  138. package/scripts/README-gurulu-scan.md +0 -98
  139. package/scripts/audit-cli-scopes.mjs +0 -204
  140. package/scripts/backfill-tenant-id.mjs +0 -172
  141. package/scripts/backfill-tenant-links.ts +0 -252
  142. package/scripts/backup-clickhouse.sh +0 -27
  143. package/scripts/backup-postgres.sh +0 -19
  144. package/scripts/bootstrap-runtime-schema.mjs +0 -87
  145. package/scripts/bootstrap-stripe.mjs +0 -158
  146. package/scripts/gurulu-agentic-install.lib.cjs +0 -762
  147. package/scripts/gurulu-agentic-install.mjs +0 -623
  148. package/scripts/gurulu-scan.lib.cjs +0 -1509
  149. package/scripts/gurulu-scan.mjs +0 -91
  150. package/scripts/gurulu-verify-install.lib.cjs +0 -334
  151. package/scripts/gurulu-verify-install.mjs +0 -59
  152. package/scripts/init-ssl.sh +0 -26
  153. package/scripts/migrate-flow-graph-enums.sh +0 -86
  154. package/scripts/monitor-disk.sh +0 -24
  155. package/scripts/patches/astro.patch.cjs +0 -74
  156. package/scripts/patches/auto-instrument/ast-helper.cjs +0 -480
  157. package/scripts/patches/auto-instrument/astro.cjs +0 -273
  158. package/scripts/patches/auto-instrument/express.cjs +0 -383
  159. package/scripts/patches/auto-instrument/fastify.cjs +0 -262
  160. package/scripts/patches/auto-instrument/hono.cjs +0 -392
  161. package/scripts/patches/auto-instrument/index.cjs +0 -80
  162. package/scripts/patches/auto-instrument/nestjs.cjs +0 -286
  163. package/scripts/patches/auto-instrument/nextjs-app-router.cjs +0 -345
  164. package/scripts/patches/auto-instrument/nextjs-pages.cjs +0 -361
  165. package/scripts/patches/auto-instrument/remix.cjs +0 -168
  166. package/scripts/patches/auto-instrument/sdk-helper-map.cjs +0 -241
  167. package/scripts/patches/auto-instrument/singleton-helper.cjs +0 -193
  168. package/scripts/patches/auto-instrument/sveltekit.cjs +0 -161
  169. package/scripts/patches/auto-instrument/vite-react.cjs +0 -37
  170. package/scripts/patches/auto-instrument/vue.cjs +0 -196
  171. package/scripts/patches/express.patch.cjs +0 -99
  172. package/scripts/patches/fastify.patch.cjs +0 -108
  173. package/scripts/patches/index.cjs +0 -300
  174. package/scripts/patches/nestjs.patch.cjs +0 -112
  175. package/scripts/patches/nextjs-app-router.patch.cjs +0 -97
  176. package/scripts/patches/nextjs-pages.patch.cjs +0 -97
  177. package/scripts/patches/remix.patch.cjs +0 -75
  178. package/scripts/patches/sveltekit.patch.cjs +0 -72
  179. package/scripts/patches/vite-react.patch.cjs +0 -73
  180. package/scripts/patches/vue.patch.cjs +0 -82
  181. package/scripts/renew-ssl.sh +0 -14
  182. package/scripts/resolve-migration.sh +0 -23
  183. package/scripts/seed-cli-dev-keys.mjs +0 -130
  184. package/scripts/seed-test-data.mjs +0 -391
  185. package/scripts/spike-browserless.ts +0 -65
  186. package/scripts/tenant-pivot-consistency-check.mjs +0 -205
  187. package/scripts/tenant-pivot-phase-3-cleanup.lib.cjs +0 -258
  188. package/scripts/tenant-pivot-phase-3-cleanup.mjs +0 -98
  189. package/scripts/test-identity-resolution.ts +0 -804
  190. package/scripts/validate-gurulu-schemas.mjs +0 -79
@@ -1,1590 +0,0 @@
1
- "use strict";
2
- /**
3
- * Phase 16 A2 — `gurulu install` user-facing auto-install command.
4
- *
5
- * Runs the full bootstrap loop against a target repo:
6
- * 1. scan (scripts/gurulu-scan.mjs)
7
- * 2. confirm (interactive, skipped with --yes)
8
- * 3. agentic plan (scripts/gurulu-agentic-install.mjs --dry-run)
9
- * 4. apply (scripts/gurulu-agentic-install.mjs --apply)
10
- * 5. npm install (pm detected from lockfile)
11
- * 6. .env merge (.env.local for Next.js, .env otherwise)
12
- * 7. ingest ping (POST <ingestUrl>/api/ingest/v1/health)
13
- *
14
- * The command is designed so the flow orchestration (`runInstallFlow`) can
15
- * be unit-tested with injected deps — no real spawns or fetches required.
16
- */
17
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
- if (k2 === undefined) k2 = k;
19
- var desc = Object.getOwnPropertyDescriptor(m, k);
20
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
21
- desc = { enumerable: true, get: function() { return m[k]; } };
22
- }
23
- Object.defineProperty(o, k2, desc);
24
- }) : (function(o, m, k, k2) {
25
- if (k2 === undefined) k2 = k;
26
- o[k2] = m[k];
27
- }));
28
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
29
- Object.defineProperty(o, "default", { enumerable: true, value: v });
30
- }) : function(o, v) {
31
- o["default"] = v;
32
- });
33
- var __importStar = (this && this.__importStar) || (function () {
34
- var ownKeys = function(o) {
35
- ownKeys = Object.getOwnPropertyNames || function (o) {
36
- var ar = [];
37
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
38
- return ar;
39
- };
40
- return ownKeys(o);
41
- };
42
- return function (mod) {
43
- if (mod && mod.__esModule) return mod;
44
- var result = {};
45
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
46
- __setModuleDefault(result, mod);
47
- return result;
48
- };
49
- })();
50
- Object.defineProperty(exports, "__esModule", { value: true });
51
- exports.resolveWorkspaceRoot = resolveWorkspaceRoot;
52
- exports.resolveScriptsDir = resolveScriptsDir;
53
- exports.detectPackageManager = detectPackageManager;
54
- exports.packageInstallArgs = packageInstallArgs;
55
- exports.parseEnvFile = parseEnvFile;
56
- exports.mergeEnvFile = mergeEnvFile;
57
- exports.createDefaultDeps = createDefaultDeps;
58
- exports.runInstallFlow = runInstallFlow;
59
- exports.isTelemetryDisabled = isTelemetryDisabled;
60
- exports.maybePromptTelemetryConsent = maybePromptTelemetryConsent;
61
- exports.repoHashOf = repoHashOf;
62
- exports.pickSite = pickSite;
63
- exports.emitInstallTelemetry = emitInstallTelemetry;
64
- exports.detectCiEnv = detectCiEnv;
65
- exports.runAuthenticatedInstallFlow = runAuthenticatedInstallFlow;
66
- exports.installCommand = installCommand;
67
- const fs = __importStar(require("fs"));
68
- const os = __importStar(require("os"));
69
- const path = __importStar(require("path"));
70
- const crypto = __importStar(require("crypto"));
71
- const child_process_1 = require("child_process");
72
- const ui_1 = require("../utils/ui");
73
- const config_1 = require("../config");
74
- const api_client_1 = require("../api-client");
75
- const install_intent_proposal_1 = require("../install-intent-proposal");
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
- // ---------------------------------------------------------------------------
230
- // Script resolution
231
- // ---------------------------------------------------------------------------
232
- /**
233
- * Locate the repo scripts/ directory (works when installed from source tree
234
- * or as a bundled package; falls back to GURULU_SCRIPTS_DIR env override).
235
- */
236
- function resolveScriptsDir(startDir = __dirname) {
237
- if (process.env.GURULU_SCRIPTS_DIR && fs.existsSync(process.env.GURULU_SCRIPTS_DIR)) {
238
- return process.env.GURULU_SCRIPTS_DIR;
239
- }
240
- let dir = startDir;
241
- for (let i = 0; i < 8; i++) {
242
- const candidate = path.join(dir, 'scripts', 'gurulu-scan.mjs');
243
- if (fs.existsSync(candidate))
244
- return path.join(dir, 'scripts');
245
- const parent = path.dirname(dir);
246
- if (parent === dir)
247
- break;
248
- dir = parent;
249
- }
250
- // Default to monorepo root guess (4 levels up from packages/cli/dist/commands)
251
- return path.resolve(startDir, '..', '..', '..', '..', 'scripts');
252
- }
253
- // ---------------------------------------------------------------------------
254
- // Package-manager detection
255
- // ---------------------------------------------------------------------------
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
- }
265
- if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml')))
266
- return 'pnpm';
267
- if (fs.existsSync(path.join(repoRoot, 'yarn.lock')))
268
- return 'yarn';
269
- return 'npm';
270
- }
271
- function packageInstallArgs(pm) {
272
- if (pm === 'pnpm')
273
- return ['add', '@gurulu/web'];
274
- if (pm === 'yarn')
275
- return ['add', '@gurulu/web'];
276
- if (pm === 'bun')
277
- return ['add', '@gurulu/web'];
278
- return ['install', '@gurulu/web'];
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
- }
364
- function mergeEnvFile(repoRoot, framework, vars) {
365
- const isNext = framework.startsWith('nextjs') || framework === 'nextjs';
366
- const filename = isNext ? '.env.local' : '.env';
367
- const filePath = path.join(repoRoot, filename);
368
- let existing = '';
369
- if (fs.existsSync(filePath)) {
370
- existing = fs.readFileSync(filePath, 'utf8');
371
- }
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);
375
- const added = [];
376
- const skipped = [];
377
- const linesToAppend = [];
378
- for (const [key, value] of Object.entries(vars)) {
379
- if (existingKeys.has(key)) {
380
- skipped.push(key);
381
- continue;
382
- }
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}`);
390
- added.push(key);
391
- }
392
- if (linesToAppend.length > 0) {
393
- const separator = existing && !existing.endsWith('\n') ? '\n' : '';
394
- const header = existing ? '' : '# Gurulu.io Analytics\n';
395
- fs.appendFileSync(filePath, `${separator}${header}${linesToAppend.join('\n')}\n`);
396
- }
397
- return { file: filename, added, skipped };
398
- }
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
- // ---------------------------------------------------------------------------
432
- // Default dependency implementations (real spawn + real fetch)
433
- // ---------------------------------------------------------------------------
434
- function createDefaultDeps(scriptsDir) {
435
- const runNode = (args, opts = {}) => new Promise((resolve) => {
436
- const child = (0, child_process_1.spawn)('node', args, {
437
- cwd: opts.cwd,
438
- stdio: ['pipe', 'pipe', 'pipe'],
439
- });
440
- let stdout = '';
441
- let stderr = '';
442
- child.stdout.on('data', (c) => (stdout += c.toString()));
443
- child.stderr.on('data', (c) => (stderr += c.toString()));
444
- child.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
445
- if (opts.input) {
446
- child.stdin.write(opts.input);
447
- child.stdin.end();
448
- }
449
- });
450
- const runCmd = (cmd, args, opts = {}) => new Promise((resolve) => {
451
- const child = (0, child_process_1.spawn)(cmd, args, {
452
- cwd: opts.cwd,
453
- stdio: ['ignore', 'pipe', 'pipe'],
454
- shell: process.platform === 'win32',
455
- });
456
- let stdout = '';
457
- let stderr = '';
458
- child.stdout.on('data', (c) => (stdout += c.toString()));
459
- child.stderr.on('data', (c) => (stderr += c.toString()));
460
- child.on('close', (code) => resolve({ code: code ?? 0, stdout, stderr }));
461
- child.on('error', (err) => resolve({ code: 1, stdout, stderr: String(err) }));
462
- });
463
- const fetchJson = async (url, body) => {
464
- try {
465
- // Node 18+ has global fetch
466
- const res = await globalThis.fetch(url, {
467
- method: 'POST',
468
- headers: { 'content-type': 'application/json' },
469
- body: JSON.stringify(body),
470
- });
471
- return { ok: !!res.ok, status: res.status };
472
- }
473
- catch {
474
- return { ok: false, status: 0 };
475
- }
476
- };
477
- const verify = async (params) => {
478
- const verifierScript = path.join(params.scriptsDir, 'gurulu-verify-install.mjs');
479
- const args = [
480
- verifierScript,
481
- params.repoRoot,
482
- '--framework',
483
- params.framework,
484
- '--site-id',
485
- params.siteId,
486
- '--tenant-id',
487
- params.tenantId,
488
- '--ingest-url',
489
- params.ingestUrl,
490
- ];
491
- const res = await runNode(args);
492
- // Parse the final VERIFY_RESULT <json> line if present.
493
- const lines = (res.stdout || '').trim().split('\n');
494
- let parsed = { ok: res.code === 0, reason: null };
495
- for (let i = lines.length - 1; i >= 0; i--) {
496
- const line = lines[i];
497
- if (line.startsWith('VERIFY_RESULT ')) {
498
- try {
499
- parsed = JSON.parse(line.slice('VERIFY_RESULT '.length));
500
- }
501
- catch {
502
- // fall through
503
- }
504
- break;
505
- }
506
- }
507
- if (res.code !== 0 && parsed.ok)
508
- parsed.ok = false;
509
- return parsed;
510
- };
511
- return {
512
- runNode,
513
- runCmd,
514
- fetchJson,
515
- prompt: ui_1.prompt,
516
- verify,
517
- log: { info: ui_1.info, warn: ui_1.warn, error: ui_1.error, step: ui_1.step, success: ui_1.success },
518
- };
519
- }
520
- // ---------------------------------------------------------------------------
521
- // Orchestration
522
- // ---------------------------------------------------------------------------
523
- function log(deps, level, msg) {
524
- const l = deps.log;
525
- if (!l)
526
- return;
527
- l[level](msg);
528
- }
529
- function getEnvPrefix(framework) {
530
- if (framework.startsWith('nextjs'))
531
- return 'NEXT_PUBLIC_';
532
- if (framework === 'vite-react' || framework === 'vite')
533
- return 'VITE_';
534
- if (framework === 'nuxt' || framework === 'vue')
535
- return 'NUXT_PUBLIC_';
536
- if (framework === 'sveltekit' || framework === 'svelte')
537
- return 'PUBLIC_';
538
- if (framework === 'astro')
539
- return 'PUBLIC_';
540
- return ''; // Express, Fastify, NestJS don't need prefix
541
- }
542
- async function runInstallFlow(args, deps, scriptsDir) {
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);
548
- const summary = {
549
- scan: null,
550
- framework: null,
551
- planDiff: '',
552
- filesChanged: 0,
553
- packageManager: null,
554
- packageInstalled: false,
555
- envKeysWritten: [],
556
- ingestOk: null,
557
- dryRun: !!args.dryRun,
558
- verify: null,
559
- rolledBack: false,
560
- errors: [],
561
- };
562
- if (!args.siteId) {
563
- const msg = 'Missing --site-id. Provide --site-id or run without --yes to enter interactively.';
564
- summary.errors.push(msg);
565
- log(deps, 'error', msg);
566
- return summary;
567
- }
568
- if (!args.tenantId) {
569
- const msg = 'Missing --tenant-id. Provide --tenant-id or run without --yes to enter interactively.';
570
- summary.errors.push(msg);
571
- log(deps, 'error', msg);
572
- return summary;
573
- }
574
- const scanScript = path.join(scriptsDir, 'gurulu-scan.mjs');
575
- const agenticScript = path.join(scriptsDir, 'gurulu-agentic-install.mjs');
576
- // ---- 1. Scan ---------------------------------------------------------
577
- log(deps, 'step', `Scanning ${(0, ui_1.cyan)(repoRoot)}...`);
578
- const scanRes = await deps.runNode([scanScript, repoRoot, '--quiet']);
579
- if (scanRes.code !== 0) {
580
- const msg = `Scan failed (exit ${scanRes.code}): ${scanRes.stderr.trim()}`;
581
- summary.errors.push(msg);
582
- log(deps, 'error', msg);
583
- return summary;
584
- }
585
- try {
586
- summary.scan = JSON.parse(scanRes.stdout);
587
- }
588
- catch (err) {
589
- const msg = `Scan produced invalid JSON: ${err.message}`;
590
- summary.errors.push(msg);
591
- log(deps, 'error', msg);
592
- return summary;
593
- }
594
- const detectedFw = args.framework || summary.scan.framework?.name || 'auto';
595
- summary.framework = detectedFw;
596
- log(deps, 'info', `Framework: ${(0, ui_1.bold)(detectedFw)} · ORM: ${summary.scan.orm?.name || 'none'} · Auth: ${summary.scan.auth?.name || 'none'} · Routes: ${summary.scan.routes?.length ?? 0}`);
597
- // ---- 2. Confirm ------------------------------------------------------
598
- if (!args.yes) {
599
- const answer = await deps.prompt(' Continue with install? (Y/n): ');
600
- if (answer && answer.toLowerCase().startsWith('n')) {
601
- log(deps, 'info', 'Aborted by user.');
602
- return summary;
603
- }
604
- }
605
- // ---- 3. Agentic dry-run (plan / diff) --------------------------------
606
- log(deps, 'step', 'Generating patch plan (dry-run)...');
607
- const planArgs = [
608
- agenticScript,
609
- repoRoot,
610
- '--dry-run',
611
- '--site-id',
612
- args.siteId,
613
- '--tenant-id',
614
- args.tenantId,
615
- ];
616
- if (args.framework) {
617
- planArgs.push('--framework', args.framework);
618
- }
619
- // Sprint A fix A2 — forward the active profile's secret key so the planner
620
- // can call the LLM property-extraction endpoint while building the diff.
621
- if (args.authToken) {
622
- planArgs.push('--token', args.authToken);
623
- }
624
- // Phase 18.7 B — include auto-instrument diff in the dry-run plan so the
625
- // consent prompt / preview shows both script-tag + track-call changes.
626
- if (args.autoInstrument) {
627
- planArgs.push('--auto-instrument');
628
- if (args.autoProperties) {
629
- planArgs.push('--auto-properties');
630
- }
631
- if (args.intentResultPath) {
632
- planArgs.push('--intent-result', args.intentResultPath);
633
- }
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
- }
645
- const planRes = await deps.runNode(planArgs);
646
- summary.planDiff = planRes.stdout;
647
- if (planRes.code !== 0) {
648
- const msg = `Planner failed (exit ${planRes.code}): ${planRes.stderr.trim()}`;
649
- summary.errors.push(msg);
650
- log(deps, 'error', msg);
651
- return summary;
652
- }
653
- if (planRes.stdout.trim()) {
654
- log(deps, 'info', 'Proposed changes:');
655
- for (const line of planRes.stdout.split('\n').slice(0, 40)) {
656
- if (line)
657
- log(deps, 'info', ` ${(0, ui_1.dim)(line)}`);
658
- }
659
- }
660
- // crude file-count: `+++ b/<path>` lines in unified diff
661
- summary.filesChanged = (planRes.stdout.match(/^\+\+\+ /gm) || []).length;
662
- // ---- 4. Apply (skipped on --dry-run) ---------------------------------
663
- if (!args.dryRun) {
664
- log(deps, 'step', 'Applying patches...');
665
- const applyArgs = [
666
- agenticScript,
667
- repoRoot,
668
- '--apply',
669
- '--site-id',
670
- args.siteId,
671
- '--tenant-id',
672
- args.tenantId,
673
- ];
674
- if (args.framework) {
675
- applyArgs.push('--framework', args.framework);
676
- }
677
- // Sprint A fix A2 — forward the active profile's secret key so the
678
- // agentic-install script's `tryLlmExtraction` can authenticate against
679
- // the LLM property-extraction endpoint. Without it the request 401s and
680
- // every injected `gurulu.track(...)` falls back to `// TODO: <prop>`
681
- // placeholders, breaking the auto-instrument feature.
682
- if (args.authToken) {
683
- applyArgs.push('--token', args.authToken);
684
- }
685
- // Phase 18.7 B — forward auto-instrument flag + intent result path.
686
- if (args.autoInstrument) {
687
- applyArgs.push('--auto-instrument');
688
- if (args.autoProperties) {
689
- applyArgs.push('--auto-properties');
690
- }
691
- if (args.intentResultPath) {
692
- applyArgs.push('--intent-result', args.intentResultPath);
693
- }
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
- }
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
- }
714
- if (applyRes.code !== 0) {
715
- const msg = `Apply failed (exit ${applyRes.code}): ${applyRes.stderr.trim()}`;
716
- summary.errors.push(msg);
717
- log(deps, 'error', msg);
718
- return summary;
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);
741
- // Parse the machine-readable auto-instrument result line if present.
742
- if (args.autoInstrument) {
743
- const lines = (applyRes.stdout || '').split('\n');
744
- for (let i = lines.length - 1; i >= 0; i--) {
745
- if (lines[i].startsWith('AUTO_INSTRUMENT_RESULT ')) {
746
- try {
747
- const parsed = JSON.parse(lines[i].slice('AUTO_INSTRUMENT_RESULT '.length));
748
- summary.instrumentation = {
749
- enabled: true,
750
- filesModified: parsed.filesModified || 0,
751
- eventsInstrumented: parsed.eventsInstrumented || 0,
752
- eventsSkipped: parsed.eventsSkipped || 0,
753
- notes: parsed.notes || [],
754
- };
755
- }
756
- catch {
757
- // fall through — telemetry will show enabled:true with zeros.
758
- }
759
- break;
760
- }
761
- }
762
- if (!summary.instrumentation) {
763
- summary.instrumentation = {
764
- enabled: true,
765
- filesModified: 0,
766
- eventsInstrumented: 0,
767
- eventsSkipped: 0,
768
- };
769
- }
770
- }
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
- }
795
- }
796
- else {
797
- if (args.autoInstrument) {
798
- log(deps, 'info', `Auto-instrument: ${summary.instrumentation?.filesModified || 0} route files, ${summary.instrumentation?.eventsInstrumented || 0} events`);
799
- }
800
- log(deps, 'info', 'Dry-run: skipping apply, npm install, env merge, and ingest test.');
801
- }
802
- // ---- 5. npm install --------------------------------------------------
803
- const pm = detectPackageManager(repoRoot);
804
- summary.packageManager = pm;
805
- if (args.skipNpm || args.dryRun) {
806
- log(deps, 'info', `Skipping package install (${pm} add @gurulu/web)`);
807
- }
808
- else {
809
- log(deps, 'step', `Installing @gurulu/web with ${(0, ui_1.bold)(pm)}...`);
810
- const pmRes = await deps.runCmd(pm, packageInstallArgs(pm), { cwd: repoRoot });
811
- if (pmRes.code !== 0) {
812
- const msg = `${pm} install failed (exit ${pmRes.code}): ${pmRes.stderr.trim()}`;
813
- summary.errors.push(msg);
814
- log(deps, 'warn', msg);
815
- }
816
- else {
817
- summary.packageInstalled = true;
818
- log(deps, 'success', '@gurulu/web installed.');
819
- }
820
- }
821
- // ---- 6. .env merge ---------------------------------------------------
822
- const ingestUrl = args.ingestUrl || process.env.GURULU_INGEST_URL || 'https://gurulu.io';
823
- const envPrefix = getEnvPrefix(detectedFw);
824
- const envVars = {
825
- [`${envPrefix}GURULU_SITE_ID`]: args.siteId,
826
- [`${envPrefix}GURULU_TENANT_ID`]: args.tenantId,
827
- [`${envPrefix}GURULU_INGEST_URL`]: ingestUrl,
828
- };
829
- if (args.skipEnv || args.dryRun) {
830
- log(deps, 'info', 'Skipping .env merge.');
831
- }
832
- else {
833
- const mergeRes = mergeEnvFile(repoRoot, detectedFw, envVars);
834
- summary.envKeysWritten = mergeRes.added;
835
- if (mergeRes.added.length > 0) {
836
- log(deps, 'success', `Wrote ${mergeRes.added.length} key(s) to ${mergeRes.file}`);
837
- }
838
- if (mergeRes.skipped.length > 0) {
839
- log(deps, 'warn', `Skipped existing key(s): ${mergeRes.skipped.join(', ')}`);
840
- }
841
- }
842
- // ---- 7. Ingest ping --------------------------------------------------
843
- if (!args.dryRun) {
844
- const pingUrl = `${ingestUrl.replace(/\/$/, '')}/api/ingest/v1/health`;
845
- log(deps, 'step', `Pinging ingest (${pingUrl})...`);
846
- const pingRes = await deps.fetchJson(pingUrl, {
847
- siteId: args.siteId,
848
- tenantId: args.tenantId,
849
- source: 'gurulu-cli-install',
850
- });
851
- summary.ingestOk = pingRes.ok;
852
- if (pingRes.ok) {
853
- log(deps, 'success', `Ingest reachable (status ${pingRes.status}).`);
854
- }
855
- else {
856
- log(deps, 'warn', `Ingest ping failed (status ${pingRes.status}). Verify GURULU_INGEST_URL.`);
857
- }
858
- }
859
- // ---- 8. Live smoke verify (Phase 16 A3) ------------------------------
860
- if (args.verify && !args.dryRun) {
861
- if (!deps.verify) {
862
- log(deps, 'warn', 'Verify flag set but no verifier dep configured; skipping.');
863
- }
864
- else {
865
- log(deps, 'step', 'Running live smoke verification...');
866
- try {
867
- const verifyRes = await deps.verify({
868
- repoRoot,
869
- framework: detectedFw,
870
- siteId: args.siteId,
871
- tenantId: args.tenantId,
872
- ingestUrl,
873
- scriptsDir,
874
- });
875
- summary.verify = verifyRes;
876
- if (verifyRes.ok) {
877
- log(deps, 'success', 'Install verified: $page_view captured.');
878
- }
879
- else {
880
- const reason = verifyRes.reason || 'unknown';
881
- const msg = `Verification failed: ${reason}. Rolling back patches...`;
882
- summary.errors.push(msg);
883
- log(deps, 'error', msg);
884
- // Auto-rollback
885
- const rollbackArgs = [agenticScript, repoRoot, '--rollback'];
886
- const rbRes = await deps.runNode(rollbackArgs);
887
- if (rbRes.code === 0) {
888
- summary.rolledBack = true;
889
- log(deps, 'warn', 'Patches rolled back.');
890
- }
891
- else {
892
- log(deps, 'error', `Rollback failed (exit ${rbRes.code}): ${rbRes.stderr.trim()}`);
893
- }
894
- }
895
- }
896
- catch (err) {
897
- const msg = `Verify threw: ${err.message}`;
898
- summary.errors.push(msg);
899
- log(deps, 'error', msg);
900
- }
901
- }
902
- }
903
- // ---- Final summary ---------------------------------------------------
904
- log(deps, 'info', '');
905
- log(deps, 'info', (0, ui_1.bold)(' Install summary'));
906
- log(deps, 'step', `Framework: ${summary.framework}`);
907
- log(deps, 'step', `Files changed: ${summary.filesChanged}`);
908
- log(deps, 'step', `Package manager: ${summary.packageManager}`);
909
- log(deps, 'step', `Package installed: ${summary.packageInstalled ? 'yes' : 'no'}`);
910
- log(deps, 'step', `.env keys written: ${summary.envKeysWritten.join(', ') || 'none'}`);
911
- log(deps, 'step', `Ingest test: ${summary.ingestOk === null ? 'skipped' : summary.ingestOk ? 'ok' : 'failed'}`);
912
- if (summary.verify) {
913
- log(deps, 'step', `Live verify: ${summary.verify.ok ? 'verified' : `failed (${summary.verify.reason || 'unknown'})`}`);
914
- if (summary.rolledBack) {
915
- log(deps, 'step', 'Patches: rolled back');
916
- }
917
- }
918
- if (summary.instrumentation && summary.instrumentation.enabled) {
919
- log(deps, 'step', `Auto-instrument: ${summary.instrumentation.filesModified} file(s), ` +
920
- `${summary.instrumentation.eventsInstrumented} event(s), ` +
921
- `${summary.instrumentation.eventsSkipped} skipped`);
922
- }
923
- log(deps, 'info', '');
924
- // ---- Post-install guidance ---------------------------------------------
925
- if (!summary.dryRun && summary.errors.length === 0) {
926
- console.log('');
927
- log(deps, 'info', '✓ Install complete! Next steps:');
928
- log(deps, 'info', ` Run ${(0, ui_1.cyan)('gurulu doctor')} to verify setup health`);
929
- log(deps, 'info', ` Run ${(0, ui_1.cyan)('gurulu events tail')} to watch live events`);
930
- log(deps, 'info', ` Visit ${(0, ui_1.cyan)('https://gurulu.io/dashboard')} to view analytics`);
931
- console.log('');
932
- // AI assistant integration prompt
933
- const siteId = args.siteId || args.site || '';
934
- console.log((0, ui_1.dim)(' ─────────────────────────────────────────────────'));
935
- console.log('');
936
- log(deps, 'info', `${(0, ui_1.cyan)('AI Assistant Integration')} (Claude Code / Cursor / Codex)`);
937
- console.log('');
938
- log(deps, 'info', ` ${(0, ui_1.dim)('MCP Server (real-time analytics in your editor):')}`);
939
- log(deps, 'info', ` ${(0, ui_1.cyan)('npx @gurulu/mcp-server')} with env GURULU_API_KEY`);
940
- console.log('');
941
- log(deps, 'info', ` ${(0, ui_1.dim)('Or paste this prompt into your AI assistant:')}`);
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
- });
954
- console.log((0, ui_1.dim)(' ┌─────────────────────────────────────────────'));
955
- for (const line of promptText.split('\n')) {
956
- console.log((0, ui_1.dim)(' │ ') + line);
957
- }
958
- console.log((0, ui_1.dim)(' └─────────────────────────────────────────────'));
959
- console.log('');
960
- // Goals & funnels management
961
- log(deps, 'info', `${(0, ui_1.cyan)('Goals & Funnels')}`);
962
- log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu goals list')} ${(0, ui_1.dim)('List all conversion goals')}`);
963
- log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu goals create')} ${(0, ui_1.dim)('Create a new goal interactively')}`);
964
- log(deps, 'info', ` ${(0, ui_1.cyan)('gurulu funnels create')} ${(0, ui_1.dim)('Define a multi-step funnel')}`);
965
- console.log('');
966
- // Server-side setup
967
- log(deps, 'info', `${(0, ui_1.cyan)('Server-Side Tracking')}`);
968
- log(deps, 'info', ` ${(0, ui_1.dim)('Add server-side event tracking with:')} ${(0, ui_1.cyan)('gurulu add-server')}`);
969
- log(deps, 'info', ` ${(0, ui_1.dim)('Or manually:')}`);
970
- log(deps, 'info', ` ${(0, ui_1.cyan)('npm install @gurulu/node')}`);
971
- log(deps, 'info', ` ${(0, ui_1.dim)('import { Gurulu } from \'@gurulu/node\';')}`);
972
- log(deps, 'info', ` ${(0, ui_1.dim)('const gurulu = new Gurulu({ siteId, serverApiKey });')}`);
973
- log(deps, 'info', ` ${(0, ui_1.dim)('gurulu.track(\'order_completed\', { userId, revenue });')}`);
974
- console.log('');
975
- // DataLayer Bridge for GTM
976
- log(deps, 'info', `${(0, ui_1.cyan)('DataLayer Bridge')} ${(0, ui_1.dim)('(for GTM users)')}`);
977
- log(deps, 'info', ` ${(0, ui_1.dim)('Auto-capture all Google Tag Manager events:')}`);
978
- log(deps, 'info', ` ${(0, ui_1.cyan)('window.gurulu.loadDataLayerBridge()')}`);
979
- console.log('');
980
- // identify() guidance
981
- log(deps, 'info', `${(0, ui_1.cyan)('User Identification')}`);
982
- log(deps, 'info', ` ${(0, ui_1.dim)('Web:')} ${(0, ui_1.cyan)('window.gurulu.identify(\'user_123\', { email, plan })')}`);
983
- log(deps, 'info', ` ${(0, ui_1.dim)('Server:')} ${(0, ui_1.cyan)('gurulu.identify(\'user_123\', { email, plan })')}`);
984
- log(deps, 'info', ` ${(0, ui_1.dim)('Call on login/signup to link events to known users.')}`);
985
- console.log('');
986
- }
987
- return summary;
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
- }
1070
- function repoHashOf(repoRoot, remoteUrl = null) {
1071
- // Consent-gated: when telemetry is off, never compute the hash.
1072
- if (isTelemetryDisabled())
1073
- return '';
1074
- const h = crypto.createHash('sha256');
1075
- h.update(path.resolve(repoRoot));
1076
- if (remoteUrl)
1077
- h.update('\n' + remoteUrl);
1078
- return h.digest('hex');
1079
- }
1080
- function pickSite(sites, target) {
1081
- if (!target)
1082
- return sites.length === 1 ? sites[0] : null;
1083
- return (sites.find((s) => s.id === target) ||
1084
- sites.find((s) => s.name === target) ||
1085
- sites.find((s) => s.id.startsWith(target)) ||
1086
- null);
1087
- }
1088
- /** Send a telemetry POST. Never throws — quota errors are surfaced via `ok`. */
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
- }
1096
- try {
1097
- const res = await deps.postVerify({
1098
- source: 'cli',
1099
- ...payload,
1100
- });
1101
- if (res.status === 402)
1102
- return { ok: false, quotaExceeded: true };
1103
- return { ok: !!res.ok, quotaExceeded: false };
1104
- }
1105
- catch {
1106
- return { ok: false, quotaExceeded: false };
1107
- }
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
- }
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
- }
1151
- // Site selection
1152
- let selected = pickSite(authDeps.sites, args.site);
1153
- if (!selected) {
1154
- if (authDeps.sites.length === 0) {
1155
- const msg = 'No sites found. Run `gurulu sites create --domain ...` first.';
1156
- installDeps.log?.error(msg);
1157
- return {
1158
- scan: null,
1159
- framework: null,
1160
- planDiff: '',
1161
- filesChanged: 0,
1162
- packageManager: null,
1163
- packageInstalled: false,
1164
- envKeysWritten: [],
1165
- ingestOk: null,
1166
- dryRun: !!args.dryRun,
1167
- verify: null,
1168
- rolledBack: false,
1169
- errors: [msg],
1170
- };
1171
- }
1172
- if (args.yes) {
1173
- // Non-interactive, multiple sites: pick the first.
1174
- selected = authDeps.sites[0];
1175
- }
1176
- else if (authDeps.promptSelect) {
1177
- const labels = authDeps.sites.map((s) => `${s.name || s.id} (${s.domain})`);
1178
- const idx = await authDeps.promptSelect(' Select site: ', labels);
1179
- selected = authDeps.sites[idx] || authDeps.sites[0];
1180
- }
1181
- else {
1182
- selected = authDeps.sites[0];
1183
- }
1184
- }
1185
- const filled = {
1186
- ...args,
1187
- siteId: selected.id,
1188
- tenantId: authDeps.tenantId,
1189
- };
1190
- // ---- Phase 18.6 — Intent discovery (pre-scan pass) ------------------
1191
- // Runs BEFORE the core flow so the user sees the proposal first. We use
1192
- // the scanner in --quiet mode, feed signals to the injected analyzer,
1193
- // and render a proposal. On --skip-intent or missing deps we fall
1194
- // straight through to the Phase 18.5 authenticated flow.
1195
- const intentRecord = {
1196
- skipped: !!args.skipIntent,
1197
- dryRun: !!args.intentDryRun,
1198
- analyzed: false,
1199
- preSeeded: false,
1200
- };
1201
- if (!args.skipIntent && authDeps.intent) {
1202
- const repoRoot = path.resolve(args.path || process.cwd());
1203
- const scanScript = path.join(scriptsDir, 'gurulu-scan.mjs');
1204
- installDeps.log?.step('Scanning for install-time intent signals...');
1205
- const scanRes = await installDeps.runNode([scanScript, repoRoot, '--quiet']);
1206
- if (scanRes.code !== 0) {
1207
- installDeps.log?.warn(`Intent scan failed (exit ${scanRes.code}); skipping intent discovery.`);
1208
- intentRecord.error = `scan_failed:${scanRes.code}`;
1209
- }
1210
- else {
1211
- let signals = null;
1212
- try {
1213
- signals = JSON.parse(scanRes.stdout);
1214
- }
1215
- catch (err) {
1216
- installDeps.log?.warn(`Intent scan produced invalid JSON: ${err.message}; skipping.`);
1217
- intentRecord.error = 'scan_invalid_json';
1218
- }
1219
- if (signals) {
1220
- let intent = null;
1221
- try {
1222
- intent = await authDeps.intent.analyze(signals);
1223
- intentRecord.analyzed = true;
1224
- intentRecord.vertical = intent.vertical;
1225
- if (intent.analyzerMode === 'heuristic') {
1226
- installDeps.log?.warn('⚠ LLM unavailable — using rule-based intent discovery. Results may be less precise.');
1227
- }
1228
- }
1229
- catch (err) {
1230
- installDeps.log?.warn(`Intent analyzer failed: ${err.message}`);
1231
- installDeps.log?.warn('⚠ Falling back to built-in generic event set.');
1232
- intentRecord.error = `analyze_failed:${err.message}`;
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';
1240
- // Client-side fallback: produce a minimal generic intent so
1241
- // auto-instrument and pre-seed still work even when the API is down.
1242
- intent = {
1243
- vertical: 'generic',
1244
- confidence: 'low',
1245
- reasoning: 'API unavailable — using built-in generic fallback.',
1246
- analyzerMode: 'heuristic',
1247
- alternativeVerticals: [],
1248
- events: [
1249
- { name: '$page_view', category: 'engagement', source: 'inferred:pattern', properties: [], confidence: 1.0, reasoning: 'Baseline page impression.' },
1250
- { name: '$session_start', category: 'engagement', source: 'inferred:pattern', properties: [], confidence: 1.0, reasoning: 'Session boundary.' },
1251
- { name: '$signup', category: 'acquisition', source: 'inferred:pattern', properties: [{ name: 'method', type: 'string' }], confidence: 0.9, reasoning: 'New account signal.' },
1252
- { name: '$login', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'method', type: 'string' }], confidence: 0.9, reasoning: 'Returning user signal.' },
1253
- { name: '$logout', category: 'engagement', source: 'inferred:pattern', properties: [], confidence: 0.9, reasoning: 'Session end.' },
1254
- { name: '$first_action', category: 'activation', source: 'inferred:pattern', properties: [], confidence: 0.8, reasoning: 'First meaningful interaction.' },
1255
- { name: 'click', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'selector', type: 'string' }], confidence: 1.0, reasoning: 'User clicks.' },
1256
- { name: 'scroll_depth', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'depth', type: 'number' }], confidence: 1.0, reasoning: 'Scroll engagement.' },
1257
- { name: 'form_submit', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'form_id', type: 'string' }], confidence: 0.9, reasoning: 'Form completion.' },
1258
- { name: 'engaged_session', category: 'engagement', source: 'inferred:pattern', properties: [], confidence: 1.0, reasoning: 'Quality session marker.' },
1259
- { name: '$search', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'query', type: 'string' }], confidence: 0.7, reasoning: 'Search intent.' },
1260
- { name: '$error', category: 'engagement', source: 'inferred:pattern', properties: [{ name: 'message', type: 'string' }], confidence: 0.8, reasoning: 'Client error tracking.' },
1261
- ],
1262
- funnels: [
1263
- { name: 'Signup Flow', category: 'acquisition', steps: ['$page_view', '$signup', '$login'], reasoning: 'Page view to signup to first login.' },
1264
- { name: 'Engagement', category: 'engagement', steps: ['$page_view', 'scroll_depth', 'engaged_session'], reasoning: 'Page view to scroll to engaged session.' },
1265
- ],
1266
- };
1267
- }
1268
- if (intent) {
1269
- const io = authDeps.intent.io || {
1270
- print: (l) => console.log(l),
1271
- prompt: installDeps.prompt,
1272
- isPiped: !process.stdout.isTTY,
1273
- };
1274
- const nonInteractive = !!args.yes || io.isPiped || !!args.intentDryRun;
1275
- const decision = await (0, install_intent_proposal_1.runProposal)({ intent, io, nonInteractive });
1276
- intentRecord.accepted = {
1277
- events: decision.accepted.events.length,
1278
- funnels: decision.accepted.funnels.length,
1279
- };
1280
- // Phase 18.7 B — stash the raw ProposedEvent list for the
1281
- // auto-instrument bridge below. Not part of the persisted summary
1282
- // shape — marked with an underscore so TypeScript narrowing sees
1283
- // the public `accepted` counts only.
1284
- intentRecord._acceptedEvents = decision.accepted.events;
1285
- if (decision.quit) {
1286
- installDeps.log?.info('Intent proposal dismissed by user.');
1287
- }
1288
- else if (args.intentDryRun) {
1289
- installDeps.log?.info(`Intent dry-run: would pre-seed ${decision.accepted.events.length} events and ${decision.accepted.funnels.length} funnels.`);
1290
- }
1291
- else if (decision.accepted.events.length === 0 && decision.accepted.funnels.length === 0) {
1292
- installDeps.log?.info('No events or funnels accepted; skipping pre-seed.');
1293
- }
1294
- else {
1295
- const res = await authDeps.intent.preSeed({
1296
- siteId: selected.id,
1297
- intent,
1298
- accepted: decision.accepted,
1299
- rejected: decision.rejected,
1300
- });
1301
- if (res.quotaExceeded) {
1302
- const msg = 'Install quota exceeded during pre-seed. Upgrade at https://gurulu.io/settings/billing';
1303
- installDeps.log?.error(msg);
1304
- intentRecord.error = 'quota_exceeded';
1305
- }
1306
- else if (!res.ok) {
1307
- installDeps.log?.warn(`Pre-seed failed: ${res.error || 'unknown error'}`);
1308
- intentRecord.error = res.error || 'pre_seed_failed';
1309
- }
1310
- else {
1311
- intentRecord.preSeeded = true;
1312
- intentRecord.proposalId = res.proposalId;
1313
- intentRecord.created = res.created;
1314
- installDeps.log?.success(`Pre-seeded ${res.created?.goals || 0} goals, ${res.created?.funnels || 0} funnels, ${res.created?.milestones || 0} milestones.`);
1315
- }
1316
- }
1317
- }
1318
- }
1319
- }
1320
- }
1321
- else if (args.skipIntent) {
1322
- installDeps.log?.info('Intent discovery skipped (--skip-intent).');
1323
- }
1324
- // ---- Phase 18.7 B — Auto-instrumentation consent + intent-result file
1325
- // When --auto-instrument is set AND an intent with accepted events exists,
1326
- // serialize the decision to a tmp file so the agentic-install script can
1327
- // read it during --apply. We also show a prominent consent prompt listing
1328
- // the route files that will be touched (skippable with --yes).
1329
- let autoInstrumentEnabled = !!args.autoInstrument;
1330
- let intentResultPath;
1331
- if (autoInstrumentEnabled) {
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) {
1337
- installDeps.log?.warn('Auto-instrument requested but no accepted events from intent discovery; disabling.');
1338
- autoInstrumentEnabled = false;
1339
- }
1340
- else {
1341
- // Persist an `{ accepted: { events } }` envelope matching the
1342
- // agentic-install script's reader shape. We resolve the raw ProposedEvent
1343
- // list from the proposal IO if available — otherwise we fall back to
1344
- // writing an empty envelope and rely on scan-driven route detection.
1345
- const tmpDir = process.env.TMPDIR || '/tmp';
1346
- const uuid = crypto.randomBytes(8).toString('hex');
1347
- intentResultPath = path.join(tmpDir, `gurulu-intent-${uuid}.json`);
1348
- try {
1349
- fs.writeFileSync(intentResultPath, JSON.stringify({
1350
- accepted: {
1351
- events: intentRecord._acceptedEvents || [],
1352
- },
1353
- }, null, 2), 'utf8');
1354
- }
1355
- catch (err) {
1356
- installDeps.log?.warn(`Failed to persist intent result: ${err.message}; disabling auto-instrument.`);
1357
- autoInstrumentEnabled = false;
1358
- intentResultPath = undefined;
1359
- }
1360
- // Consent prompt — unless --yes or --dry-run.
1361
- if (autoInstrumentEnabled && !args.yes && !args.dryRun) {
1362
- installDeps.log?.warn(`Auto-instrumentation will modify ${intentRecord.accepted.events} route file(s) to insert gurulu.track() calls.`);
1363
- installDeps.log?.info(' Changes are backed up to .gurulu-backup/ and can be rolled back with `gurulu install --rollback`.');
1364
- const answer = await installDeps.prompt(' Proceed with auto-instrumentation? [y/N]: ');
1365
- if (!answer || !/^y/i.test(answer.trim())) {
1366
- installDeps.log?.info('Auto-instrumentation declined.');
1367
- autoInstrumentEnabled = false;
1368
- intentResultPath = undefined;
1369
- }
1370
- }
1371
- }
1372
- }
1373
- const runArgs = {
1374
- ...filled,
1375
- autoInstrument: autoInstrumentEnabled,
1376
- intentResultPath,
1377
- // Sprint A fix A2 — propagate the active profile secret key so the core
1378
- // flow can forward it to the agentic-install script as `--token`.
1379
- authToken: authDeps.profile.secret_key,
1380
- };
1381
- // Run the core flow.
1382
- const summary = await runInstallFlow(runArgs, installDeps, scriptsDir);
1383
- summary.intent = intentRecord;
1384
- if (autoInstrumentEnabled && !summary.instrumentation) {
1385
- summary.instrumentation = {
1386
- enabled: true,
1387
- filesModified: 0,
1388
- eventsInstrumented: 0,
1389
- eventsSkipped: 0,
1390
- };
1391
- }
1392
- // Telemetry after each terminal state.
1393
- const cliVersion = (() => {
1394
- try {
1395
- // eslint-disable-next-line @typescript-eslint/no-var-requires
1396
- return require('../../package.json').version || '0.0.0';
1397
- }
1398
- catch {
1399
- return '0.0.0';
1400
- }
1401
- })();
1402
- const rh = repoHashOf(path.resolve(args.path || process.cwd()));
1403
- const fw = summary.framework || 'unknown';
1404
- if (args.dryRun) {
1405
- await emitInstallTelemetry(authDeps, {
1406
- siteId: selected.id,
1407
- status: 'dry-run',
1408
- framework: fw,
1409
- cliVersion,
1410
- repoHash: rh,
1411
- });
1412
- }
1413
- else if (summary.errors.length > 0) {
1414
- await emitInstallTelemetry(authDeps, {
1415
- siteId: selected.id,
1416
- status: 'failed',
1417
- framework: fw,
1418
- cliVersion,
1419
- repoHash: rh,
1420
- errorMessage: summary.errors[0] || null,
1421
- });
1422
- }
1423
- else {
1424
- const telemetry = await emitInstallTelemetry(authDeps, {
1425
- siteId: selected.id,
1426
- status: summary.verify?.ok ? 'verified' : 'applied',
1427
- framework: fw,
1428
- cliVersion,
1429
- repoHash: rh,
1430
- ...(summary.instrumentation
1431
- ? {
1432
- instrumentation: {
1433
- filesModified: summary.instrumentation.filesModified,
1434
- eventsInstrumented: summary.instrumentation.eventsInstrumented,
1435
- eventsSkipped: summary.instrumentation.eventsSkipped,
1436
- },
1437
- }
1438
- : {}),
1439
- });
1440
- if (telemetry.quotaExceeded) {
1441
- const msg = 'Install quota exceeded for the current plan. Upgrade at https://gurulu.io/settings/billing';
1442
- installDeps.log?.error(msg);
1443
- summary.errors.push(msg);
1444
- }
1445
- if (summary.rolledBack) {
1446
- await emitInstallTelemetry(authDeps, {
1447
- siteId: selected.id,
1448
- status: 'rolled-back',
1449
- framework: fw,
1450
- cliVersion,
1451
- repoHash: rh,
1452
- });
1453
- }
1454
- }
1455
- return summary;
1456
- }
1457
- async function installCommand(args) {
1458
- const scriptsDir = resolveScriptsDir();
1459
- const deps = createDefaultDeps(scriptsDir);
1460
- // Sprint A fix A3 — make `--verify` opt-in (default false). The verifier
1461
- // (`scripts/gurulu-verify-install.lib.cjs:312`) hard-`require`s
1462
- // `playwright-core`, which is NOT a runtime dependency of @gurulu/cli, so a
1463
- // default-on verify aborts every fresh install with "Verify threw". Treat
1464
- // verify as strictly opt-in: only `args.verify === true` runs it. We also
1465
- // patch the CLI option default in `src/index.ts` so the flag matches.
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
- }
1474
- // Legacy unauthenticated path: --site-id passed explicitly and no active profile.
1475
- const profile = await (0, config_1.getActiveProfile)({ profile: args.profile });
1476
- const legacyMode = !!args.siteId && !profile;
1477
- if (legacyMode) {
1478
- (0, ui_1.warn)('Legacy unauthenticated install. Run `gurulu login` for the full experience.');
1479
- let siteId = args.siteId || process.env.GURULU_SITE_ID;
1480
- let tenantId = args.tenantId || process.env.GURULU_TENANT_ID;
1481
- if (!args.yes) {
1482
- if (!siteId)
1483
- siteId = await deps.prompt(' Site ID: ');
1484
- if (!tenantId)
1485
- tenantId = await deps.prompt(' Tenant ID: ');
1486
- }
1487
- // Sprint A fix A2 — legacy callers can supply a token via env so that
1488
- // auto-instrument property extraction still works without a profile.
1489
- const filled = {
1490
- ...args,
1491
- siteId,
1492
- tenantId,
1493
- authToken: args.authToken || process.env.GURULU_API_KEY || process.env.GURULU_SECRET_KEY,
1494
- };
1495
- const summary = await runInstallFlow(filled, deps, scriptsDir);
1496
- if (summary.errors.length > 0)
1497
- process.exit(1);
1498
- return;
1499
- }
1500
- if (!profile) {
1501
- (0, ui_1.error)('Not logged in. Run `gurulu login` first, or provide --site-id for legacy mode.');
1502
- process.exit(1);
1503
- }
1504
- // Authenticated: fetch /me, pick site, run flow, emit telemetry.
1505
- let me;
1506
- try {
1507
- const res = await (0, api_client_1.cliApi)('/api/cli/me', { preloadedProfile: profile });
1508
- me = await res.json();
1509
- }
1510
- catch (err) {
1511
- (0, ui_1.error)(`Failed to load account: ${err.message}`);
1512
- process.exit(1);
1513
- }
1514
- const sites = (me.sites || []).map((s) => ({
1515
- id: s.id,
1516
- name: s.name,
1517
- domain: s.domain,
1518
- publishableKey: s.publishableKey,
1519
- }));
1520
- const authDeps = {
1521
- profile,
1522
- sites,
1523
- tenantId: me.tenant?.id || '',
1524
- postVerify: async (body) => {
1525
- const res = await (0, api_client_1.cliApi)('/api/cli/install/verify', {
1526
- method: 'POST',
1527
- preloadedProfile: profile,
1528
- body: JSON.stringify(body),
1529
- noExitOnError: true,
1530
- });
1531
- return { ok: res.ok, status: res.status };
1532
- },
1533
- intent: {
1534
- analyze: async (signals) => {
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;
1561
- }
1562
- },
1563
- preSeed: async (body) => {
1564
- const res = await (0, api_client_1.cliApi)('/api/cli/install/pre-seed', {
1565
- method: 'POST',
1566
- preloadedProfile: profile,
1567
- body: JSON.stringify(body),
1568
- noExitOnError: true,
1569
- });
1570
- if (res.status === 402 || res.status === 429) {
1571
- return { ok: false, quotaExceeded: true };
1572
- }
1573
- const text = await res.text();
1574
- const parsed = text ? JSON.parse(text) : {};
1575
- if (!res.ok) {
1576
- return { ok: false, error: (parsed && parsed.message) || `HTTP ${res.status}` };
1577
- }
1578
- return {
1579
- ok: true,
1580
- proposalId: parsed.proposalId,
1581
- created: parsed.created,
1582
- skipped: parsed.skipped,
1583
- };
1584
- },
1585
- },
1586
- };
1587
- const summary = await runAuthenticatedInstallFlow(args, authDeps, deps, scriptsDir);
1588
- if (summary.errors.length > 0)
1589
- process.exit(1);
1590
- }