@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.
@@ -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
- const out = [];
84
- for (const g of globs) {
85
- // Accept only the simple `dir/*` and bare `dir` forms anything fancier
86
- // (negation, `**`, alternation) falls through to the manual prompt.
87
- if (g.includes('**') || g.startsWith('!'))
113
+ // FA-1 P1-7 — workspace glob support now covers:
114
+ // - bare `dir` (single workspace)
115
+ // - `dir/*` (immediate childrennpm/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
- out.push(abs);
159
+ collected.add(abs);
99
160
  }
100
161
  }
101
162
  }
102
163
  catch {
103
164
  /* ignore */
104
165
  }
166
+ continue;
105
167
  }
106
- else {
107
- const abs = path.join(repoRoot, g);
108
- if (fs.existsSync(abs) && fs.existsSync(path.join(abs, 'package.json'))) {
109
- out.push(abs);
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 out;
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
- const existingKeys = new Set(existing
205
- .split('\n')
206
- .map((l) => l.trim())
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
- linesToAppend.push(`${key}=${value}`);
383
+ // Quote values that contain whitespace, `=`, `#`, or quotes themselves so
384
+ // they round-trip through any standard dotenv parser.
385
+ const needsQuote = /[\s="#\\]/.test(value);
386
+ const serialized = needsQuote
387
+ ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
388
+ : value;
389
+ linesToAppend.push(`${key}=${serialized}`);
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
- summary.installedComponents = ['script-tag', 'patch-log'];
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
- summary.installedComponents.push('auto-instrument');
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
- const res = await (0, api_client_1.cliApi)('/api/cli/install/analyze-intent', {
1192
- method: 'POST',
1193
- preloadedProfile: profile,
1194
- body: JSON.stringify({ signals }),
1195
- noExitOnError: true,
1196
- });
1197
- const text = await res.text();
1198
- const parsed = text ? JSON.parse(text) : {};
1199
- if (!res.ok) {
1200
- throw new Error((parsed && parsed.message) || `HTTP ${res.status}`);
1535
+ // FA-1 P0-3 bound the LLM round-trip. Without this guard a hung
1536
+ // upstream stalls the whole install (Sprint E1.2 fallback never
1537
+ // fires until the request finally errors out). 30s mirrors the
1538
+ // tryLlmExtraction timeout in scripts/gurulu-agentic-install.mjs.
1539
+ try {
1540
+ const res = await (0, api_client_1.cliApi)('/api/cli/install/analyze-intent', {
1541
+ method: 'POST',
1542
+ preloadedProfile: profile,
1543
+ body: JSON.stringify({ signals }),
1544
+ noExitOnError: true,
1545
+ signal: AbortSignal.timeout(30_000),
1546
+ });
1547
+ const text = await res.text();
1548
+ const parsed = text ? JSON.parse(text) : {};
1549
+ if (!res.ok) {
1550
+ throw new Error((parsed && parsed.message) || `HTTP ${res.status}`);
1551
+ }
1552
+ return parsed.intent;
1553
+ }
1554
+ catch (err) {
1555
+ const e = err;
1556
+ if (e && (e.name === 'AbortError' || e.name === 'TimeoutError')) {
1557
+ console.warn('[install] LLM analyze-intent timeout — falling back to heuristic intent');
1558
+ throw new Error('analyze-intent timeout');
1559
+ }
1560
+ throw err;
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>;