@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.
- package/README.md +24 -0
- package/dist/commands/attribution.d.ts +22 -0
- package/dist/commands/attribution.js +111 -0
- package/dist/commands/consent.d.ts +27 -0
- package/dist/commands/consent.js +233 -0
- package/dist/commands/conversion-paths.d.ts +19 -0
- package/dist/commands/conversion-paths.js +55 -0
- package/dist/commands/db.js +8 -0
- package/dist/commands/errors.d.ts +27 -0
- package/dist/commands/errors.js +121 -0
- package/dist/commands/identity.d.ts +16 -0
- package/dist/commands/identity.js +222 -1
- package/dist/commands/init.js +1 -1
- package/dist/commands/install.d.ts +21 -3
- package/dist/commands/install.js +562 -26
- package/dist/commands/releases.d.ts +17 -0
- package/dist/commands/releases.js +54 -0
- package/dist/commands/replay.d.ts +18 -0
- package/dist/commands/replay.js +64 -0
- package/dist/commands/secrets.d.ts +19 -0
- package/dist/commands/secrets.js +145 -0
- package/dist/commands/skad.d.ts +18 -0
- package/dist/commands/skad.js +53 -0
- package/dist/commands/upgrade.d.ts +21 -0
- package/dist/commands/upgrade.js +183 -0
- package/dist/frameworks/detect.d.ts +1 -1
- package/dist/frameworks/detect.js +60 -0
- package/dist/index.js +225 -2
- package/dist/utils/redact.d.ts +14 -0
- package/dist/utils/redact.js +48 -0
- package/package.json +1 -1
- package/scripts/bootstrap-runtime-schema.mjs +7 -25
- package/scripts/gurulu-agentic-install.lib.cjs +32 -4
- package/scripts/gurulu-agentic-install.mjs +10 -2
package/dist/commands/install.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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', {
|