@gurulu/cli 0.4.3 → 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/consent.d.ts +27 -0
- package/dist/commands/consent.js +233 -0
- package/dist/commands/db.js +8 -0
- package/dist/commands/identity.d.ts +3 -0
- package/dist/commands/identity.js +138 -1
- package/dist/commands/install.d.ts +16 -3
- package/dist/commands/install.js +389 -30
- package/dist/commands/secrets.d.ts +19 -0
- package/dist/commands/secrets.js +145 -0
- package/dist/commands/upgrade.d.ts +21 -0
- package/dist/commands/upgrade.js +183 -0
- package/dist/index.js +65 -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/dist/commands/install.js
CHANGED
|
@@ -52,15 +52,20 @@ exports.resolveWorkspaceRoot = resolveWorkspaceRoot;
|
|
|
52
52
|
exports.resolveScriptsDir = resolveScriptsDir;
|
|
53
53
|
exports.detectPackageManager = detectPackageManager;
|
|
54
54
|
exports.packageInstallArgs = packageInstallArgs;
|
|
55
|
+
exports.parseEnvFile = parseEnvFile;
|
|
55
56
|
exports.mergeEnvFile = mergeEnvFile;
|
|
56
57
|
exports.createDefaultDeps = createDefaultDeps;
|
|
57
58
|
exports.runInstallFlow = runInstallFlow;
|
|
59
|
+
exports.isTelemetryDisabled = isTelemetryDisabled;
|
|
60
|
+
exports.maybePromptTelemetryConsent = maybePromptTelemetryConsent;
|
|
58
61
|
exports.repoHashOf = repoHashOf;
|
|
59
62
|
exports.pickSite = pickSite;
|
|
60
63
|
exports.emitInstallTelemetry = emitInstallTelemetry;
|
|
64
|
+
exports.detectCiEnv = detectCiEnv;
|
|
61
65
|
exports.runAuthenticatedInstallFlow = runAuthenticatedInstallFlow;
|
|
62
66
|
exports.installCommand = installCommand;
|
|
63
67
|
const fs = __importStar(require("fs"));
|
|
68
|
+
const os = __importStar(require("os"));
|
|
64
69
|
const path = __importStar(require("path"));
|
|
65
70
|
const crypto = __importStar(require("crypto"));
|
|
66
71
|
const child_process_1 = require("child_process");
|
|
@@ -79,13 +84,69 @@ const install_intent_proposal_1 = require("../install-intent-proposal");
|
|
|
79
84
|
// interactively. The resolved path becomes `repoRoot` for the rest of the
|
|
80
85
|
// install flow.
|
|
81
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
|
+
}
|
|
82
112
|
function expandWorkspaceGlobs(repoRoot, globs) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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);
|
|
88
148
|
continue;
|
|
149
|
+
}
|
|
89
150
|
if (g.endsWith('/*')) {
|
|
90
151
|
const parent = path.join(repoRoot, g.slice(0, -2));
|
|
91
152
|
if (!fs.existsSync(parent))
|
|
@@ -95,22 +156,35 @@ function expandWorkspaceGlobs(repoRoot, globs) {
|
|
|
95
156
|
const abs = path.join(parent, entry);
|
|
96
157
|
if (fs.statSync(abs).isDirectory() &&
|
|
97
158
|
fs.existsSync(path.join(abs, 'package.json'))) {
|
|
98
|
-
|
|
159
|
+
collected.add(abs);
|
|
99
160
|
}
|
|
100
161
|
}
|
|
101
162
|
}
|
|
102
163
|
catch {
|
|
103
164
|
/* ignore */
|
|
104
165
|
}
|
|
166
|
+
continue;
|
|
105
167
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
}
|
|
110
184
|
}
|
|
111
185
|
}
|
|
112
186
|
}
|
|
113
|
-
return
|
|
187
|
+
return Array.from(collected);
|
|
114
188
|
}
|
|
115
189
|
async function resolveWorkspaceRoot(repoRoot, args, deps) {
|
|
116
190
|
const pkgPath = path.join(repoRoot, 'package.json');
|
|
@@ -180,6 +254,14 @@ function resolveScriptsDir(startDir = __dirname) {
|
|
|
180
254
|
// Package-manager detection
|
|
181
255
|
// ---------------------------------------------------------------------------
|
|
182
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
|
+
}
|
|
183
265
|
if (fs.existsSync(path.join(repoRoot, 'pnpm-lock.yaml')))
|
|
184
266
|
return 'pnpm';
|
|
185
267
|
if (fs.existsSync(path.join(repoRoot, 'yarn.lock')))
|
|
@@ -191,8 +273,94 @@ function packageInstallArgs(pm) {
|
|
|
191
273
|
return ['add', '@gurulu/web'];
|
|
192
274
|
if (pm === 'yarn')
|
|
193
275
|
return ['add', '@gurulu/web'];
|
|
276
|
+
if (pm === 'bun')
|
|
277
|
+
return ['add', '@gurulu/web'];
|
|
194
278
|
return ['install', '@gurulu/web'];
|
|
195
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
|
+
}
|
|
196
364
|
function mergeEnvFile(repoRoot, framework, vars) {
|
|
197
365
|
const isNext = framework.startsWith('nextjs') || framework === 'nextjs';
|
|
198
366
|
const filename = isNext ? '.env.local' : '.env';
|
|
@@ -201,11 +369,9 @@ function mergeEnvFile(repoRoot, framework, vars) {
|
|
|
201
369
|
if (fs.existsSync(filePath)) {
|
|
202
370
|
existing = fs.readFileSync(filePath, 'utf8');
|
|
203
371
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.filter((l) => l && !l.startsWith('#'))
|
|
208
|
-
.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);
|
|
209
375
|
const added = [];
|
|
210
376
|
const skipped = [];
|
|
211
377
|
const linesToAppend = [];
|
|
@@ -214,7 +380,13 @@ function mergeEnvFile(repoRoot, framework, vars) {
|
|
|
214
380
|
skipped.push(key);
|
|
215
381
|
continue;
|
|
216
382
|
}
|
|
217
|
-
|
|
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}`);
|
|
218
390
|
added.push(key);
|
|
219
391
|
}
|
|
220
392
|
if (linesToAppend.length > 0) {
|
|
@@ -460,6 +632,16 @@ async function runInstallFlow(args, deps, scriptsDir) {
|
|
|
460
632
|
planArgs.push('--intent-result', args.intentResultPath);
|
|
461
633
|
}
|
|
462
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
|
+
}
|
|
463
645
|
const planRes = await deps.runNode(planArgs);
|
|
464
646
|
summary.planDiff = planRes.stdout;
|
|
465
647
|
if (planRes.code !== 0) {
|
|
@@ -510,6 +692,14 @@ async function runInstallFlow(args, deps, scriptsDir) {
|
|
|
510
692
|
applyArgs.push('--intent-result', args.intentResultPath);
|
|
511
693
|
}
|
|
512
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
|
+
}
|
|
513
703
|
const applyRes = await deps.runNode(applyArgs);
|
|
514
704
|
// Sprint E1.1 — exit code 5 means the agentic-install script auto-rolled
|
|
515
705
|
// back due to an auto-instrument failure. Mark the summary as rolled back
|
|
@@ -529,9 +719,25 @@ async function runInstallFlow(args, deps, scriptsDir) {
|
|
|
529
719
|
}
|
|
530
720
|
// Sprint E1.7 — record components that were successfully installed for
|
|
531
721
|
// partial-install diagnostics in the summary.
|
|
532
|
-
|
|
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');
|
|
533
738
|
if (args.autoInstrument)
|
|
534
|
-
|
|
739
|
+
addComponent('auto-instrument');
|
|
740
|
+
summary.installedComponents = Array.from(existingComponents);
|
|
535
741
|
// Parse the machine-readable auto-instrument result line if present.
|
|
536
742
|
if (args.autoInstrument) {
|
|
537
743
|
const lines = (applyRes.stdout || '').split('\n');
|
|
@@ -780,7 +986,91 @@ async function runInstallFlow(args, deps, scriptsDir) {
|
|
|
780
986
|
}
|
|
781
987
|
return summary;
|
|
782
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
|
+
}
|
|
783
1070
|
function repoHashOf(repoRoot, remoteUrl = null) {
|
|
1071
|
+
// Consent-gated: when telemetry is off, never compute the hash.
|
|
1072
|
+
if (isTelemetryDisabled())
|
|
1073
|
+
return '';
|
|
784
1074
|
const h = crypto.createHash('sha256');
|
|
785
1075
|
h.update(path.resolve(repoRoot));
|
|
786
1076
|
if (remoteUrl)
|
|
@@ -797,6 +1087,12 @@ function pickSite(sites, target) {
|
|
|
797
1087
|
}
|
|
798
1088
|
/** Send a telemetry POST. Never throws — quota errors are surfaced via `ok`. */
|
|
799
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
|
+
}
|
|
800
1096
|
try {
|
|
801
1097
|
const res = await deps.postVerify({
|
|
802
1098
|
source: 'cli',
|
|
@@ -810,7 +1106,48 @@ async function emitInstallTelemetry(deps, payload) {
|
|
|
810
1106
|
return { ok: false, quotaExceeded: false };
|
|
811
1107
|
}
|
|
812
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
|
+
}
|
|
813
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
|
+
}
|
|
814
1151
|
// Site selection
|
|
815
1152
|
let selected = pickSite(authDeps.sites, args.site);
|
|
816
1153
|
if (!selected) {
|
|
@@ -1127,6 +1464,13 @@ async function installCommand(args) {
|
|
|
1127
1464
|
// verify as strictly opt-in: only `args.verify === true` runs it. We also
|
|
1128
1465
|
// patch the CLI option default in `src/index.ts` so the flag matches.
|
|
1129
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
|
+
}
|
|
1130
1474
|
// Legacy unauthenticated path: --site-id passed explicitly and no active profile.
|
|
1131
1475
|
const profile = await (0, config_1.getActiveProfile)({ profile: args.profile });
|
|
1132
1476
|
const legacyMode = !!args.siteId && !profile;
|
|
@@ -1188,18 +1532,33 @@ async function installCommand(args) {
|
|
|
1188
1532
|
},
|
|
1189
1533
|
intent: {
|
|
1190
1534
|
analyze: async (signals) => {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
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;
|
|
1201
1561
|
}
|
|
1202
|
-
return parsed.intent;
|
|
1203
1562
|
},
|
|
1204
1563
|
preSeed: async (body) => {
|
|
1205
1564
|
const res = await (0, api_client_1.cliApi)('/api/cli/install/pre-seed', {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI@0.4.4 — `gurulu secrets` — list & rotate keys in the tenant credential vault.
|
|
3
|
+
*
|
|
4
|
+
* Subcommands:
|
|
5
|
+
* gurulu secrets list (key names only — never values)
|
|
6
|
+
* gurulu secrets rotate --key API_TOKEN (issues new value, keeps old in 24h grace window)
|
|
7
|
+
*
|
|
8
|
+
* TODO(Sprint K): backend endpoints below are not yet live. The CLI command
|
|
9
|
+
* surface is implemented now so customers can wire automation.
|
|
10
|
+
* - GET /api/cli/secrets/list -> { secrets: [{ key, lastRotatedAt, rotationStatus }] }
|
|
11
|
+
* - POST /api/cli/secrets/rotate { key } -> { key, newRevealedOnce: string, graceUntil: ISO }
|
|
12
|
+
*/
|
|
13
|
+
export interface SecretsArgs {
|
|
14
|
+
action?: string;
|
|
15
|
+
key?: string;
|
|
16
|
+
json?: boolean;
|
|
17
|
+
profile?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function secretsCommand(args: SecretsArgs): Promise<void>;
|