@jaguilar87/gaia-ops 3.12.0 → 3.14.0

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/bin/gaia-init.js CHANGED
@@ -63,16 +63,19 @@ async function runScan() {
63
63
  const spinner = ora('Scanning project structure...').start();
64
64
 
65
65
  try {
66
- const [dirs, cloud, gitRemotes, claudeCode] = await Promise.all([
66
+ const [dirs, cloud, tfvars, gitRemotes, k8s, identity, claudeCode] = await Promise.all([
67
67
  scanDirectories(),
68
68
  scanCloudProvider(),
69
+ scanCloudValues(),
69
70
  scanGitRemotes(),
71
+ scanKubernetesFiles(),
72
+ scanProjectIdentity(),
70
73
  scanClaudeCode()
71
74
  ]);
72
75
 
73
76
  spinner.succeed('Project scanned');
74
77
 
75
- return { dirs, cloud, gitRemotes, claudeCode };
78
+ return { dirs, cloud, tfvars, gitRemotes, k8s, identity, claudeCode };
76
79
  } catch (error) {
77
80
  spinner.fail('Scan failed');
78
81
  throw error;
@@ -81,6 +84,7 @@ async function runScan() {
81
84
 
82
85
  /**
83
86
  * Detect directory structure by scanning CWD for known patterns.
87
+ * Falls back to subdirectories that contain .git if not found at CWD level.
84
88
  */
85
89
  async function scanDirectories() {
86
90
  const result = { gitops: null, terraform: null, appServices: null };
@@ -98,6 +102,7 @@ async function scanDirectories() {
98
102
  appServices: ['app-services', 'services', 'apps', 'applications', 'src']
99
103
  };
100
104
 
105
+ // First pass: check CWD level
101
106
  for (const [key, patterns] of Object.entries(candidates)) {
102
107
  for (const pattern of patterns) {
103
108
  if (entries.includes(pattern)) {
@@ -107,6 +112,33 @@ async function scanDirectories() {
107
112
  }
108
113
  }
109
114
 
115
+ // Second pass: for any still-null paths, check subdirectories with .git
116
+ const missingKeys = Object.keys(candidates).filter(k => result[k] === null);
117
+ if (missingKeys.length > 0) {
118
+ const repos = await findSubdirRepos();
119
+ for (const repo of repos) {
120
+ let repoEntries;
121
+ try {
122
+ repoEntries = await fs.readdir(repo.path);
123
+ } catch {
124
+ continue;
125
+ }
126
+
127
+ for (const key of missingKeys) {
128
+ if (result[key] !== null) continue;
129
+ for (const pattern of candidates[key]) {
130
+ if (repoEntries.includes(pattern)) {
131
+ result[key] = `./${repo.name}/${pattern}`;
132
+ break;
133
+ }
134
+ }
135
+ }
136
+
137
+ // Stop early if all found
138
+ if (Object.values(result).every(v => v !== null)) break;
139
+ }
140
+ }
141
+
110
142
  return result;
111
143
  }
112
144
 
@@ -117,18 +149,8 @@ async function scanDirectories() {
117
149
  async function scanCloudProvider() {
118
150
  const result = { provider: null, projectId: null, region: null };
119
151
 
120
- // Find terraform directory first
121
- const tfCandidates = ['terraform', 'tf', 'infrastructure', 'iac', 'infra'];
122
- let tfDir = null;
123
-
124
- for (const candidate of tfCandidates) {
125
- const path = join(CWD, candidate);
126
- if (existsSync(path)) {
127
- tfDir = path;
128
- break;
129
- }
130
- }
131
-
152
+ // Find terraform directory (CWD first, then subdirs with .git)
153
+ const tfDir = await findTerraformDir();
132
154
  if (!tfDir) return result;
133
155
 
134
156
  // Recursively find .tf and .hcl files (max 3 levels deep to avoid huge scans)
@@ -185,6 +207,395 @@ async function scanCloudProvider() {
185
207
  return result;
186
208
  }
187
209
 
210
+ /**
211
+ * Global cloud value scanner. Searches ALL files recursively across the entire
212
+ * workspace (CWD + subdirectory repos) for project ID, region, and cluster name.
213
+ *
214
+ * Uses four extraction strategies:
215
+ * A) HCL/TF key=value assignments (tfvars, terragrunt.hcl, .tf files)
216
+ * B) CI/CD YAML variables (.gitlab-ci*.yml, .github/workflows/*.yml)
217
+ * C) GCP Artifact Registry URLs (region-docker.pkg.dev/project/...)
218
+ * D) GKE/EKS resource path references (projects/P/locations/R/clusters/C)
219
+ *
220
+ * Returns the most frequently found value for each field.
221
+ */
222
+ async function scanCloudValues() {
223
+ const candidates = {
224
+ projectId: new Map(), // value -> { count, sources: Set }
225
+ region: new Map(),
226
+ clusterName: new Map()
227
+ };
228
+
229
+ /**
230
+ * Record a candidate value with its source file.
231
+ */
232
+ function record(field, value, source) {
233
+ if (!value || typeof value !== 'string') return;
234
+ // Basic sanity: skip template/variable references
235
+ if (value.includes('${') || value.includes('var.') || value.includes('local.')) return;
236
+ const map = candidates[field];
237
+ if (!map) return;
238
+ const existing = map.get(value);
239
+ if (existing) {
240
+ existing.count++;
241
+ existing.sources.add(source);
242
+ } else {
243
+ map.set(value, { count: 1, sources: new Set([source]) });
244
+ }
245
+ }
246
+
247
+ // ---- Collect all searchable directories: CWD + subdirectory repos ----
248
+ const searchRoots = [CWD];
249
+ const repos = await findSubdirRepos();
250
+ for (const repo of repos) {
251
+ searchRoots.push(repo.path);
252
+ }
253
+
254
+ // ---- Gather all relevant files across every root ----
255
+ const relevantExtensions = [
256
+ '.tfvars', '.hcl', '.tf',
257
+ '.yml', '.yaml'
258
+ ];
259
+
260
+ const skipDirs = new Set([
261
+ 'node_modules', '.git', '.terraform', 'vendor', 'dist',
262
+ 'build', '__pycache__', '.next', '.cache', '.venv', 'venv'
263
+ ]);
264
+
265
+ const allFiles = await findAllFiles(searchRoots, relevantExtensions, skipDirs, 8, 500);
266
+
267
+ // ---- HCL key=value patterns ----
268
+ const projectPatterns = ['project', 'project_id', 'gcp_project', 'google_project', 'aws_account_id'];
269
+ const regionPatterns = ['region', 'gcp_region', 'aws_region', 'location'];
270
+ const clusterPatterns = ['cluster_name', 'cluster', 'gke_cluster_name', 'eks_cluster_name'];
271
+
272
+ // ---- CI/CD YAML variable patterns (case-insensitive substring match) ----
273
+ const ciProjectPatterns = ['project_id', 'project-id', 'gcp_project', 'account_id'];
274
+ const ciRegionPatterns = ['region'];
275
+ const ciClusterPatterns = ['cluster_name', 'cluster-name'];
276
+
277
+ // ---- Regex for GCP Artifact Registry URLs ----
278
+ // Matches: us-east4-docker.pkg.dev/oci-pos-dev-471216/registry-name
279
+ const artifactRegistryRegex = /([a-z]+-[a-z0-9]+(?:-[a-z0-9]+)*)-docker\.pkg\.dev\/([^/\s"']+)/g;
280
+
281
+ // ---- Regex for GKE/EKS full resource paths ----
282
+ // Matches: projects/PROJECT/locations/REGION/clusters/CLUSTER
283
+ const gkePathRegex = /projects\/([^/\s"']+)\/locations\/([^/\s"']+)\/clusters\/([^/\s"']+)/g;
284
+
285
+ for (const file of allFiles) {
286
+ try {
287
+ const content = await fs.readFile(file, 'utf-8');
288
+ const ext = file.substring(file.lastIndexOf('.'));
289
+ const base = basename(file);
290
+
291
+ // ----- Strategy A: HCL/TF key=value assignments -----
292
+ if (ext === '.tfvars' || ext === '.tf' || base === 'terragrunt.hcl') {
293
+ const assignmentRegex = /^\s*(\w+)\s*=\s*"([^"]+)"/gm;
294
+ let m;
295
+ while ((m = assignmentRegex.exec(content)) !== null) {
296
+ const keyLower = m[1].toLowerCase();
297
+ const value = m[2];
298
+ if (projectPatterns.includes(keyLower)) record('projectId', value, file);
299
+ if (regionPatterns.includes(keyLower)) record('region', value, file);
300
+ if (clusterPatterns.includes(keyLower)) record('clusterName', value, file);
301
+ }
302
+ }
303
+
304
+ // ----- Strategy B: CI/CD YAML variables -----
305
+ if (ext === '.yml' || ext === '.yaml') {
306
+ // Match patterns like: KEY: value or KEY: "value"
307
+ const yamlKvRegex = /^\s*([\w-]+)\s*:\s*["']?([^"'\s#][^"'\n#]*)["']?\s*$/gm;
308
+ let m;
309
+ while ((m = yamlKvRegex.exec(content)) !== null) {
310
+ const keyLower = m[1].toLowerCase();
311
+ const value = m[2].trim();
312
+ // Skip template references and multiline indicators
313
+ if (value.startsWith('{') || value.startsWith('|') || value.startsWith('>')) continue;
314
+
315
+ if (ciProjectPatterns.some(p => keyLower.includes(p))) record('projectId', value, file);
316
+ if (ciRegionPatterns.some(p => keyLower.includes(p))) record('region', value, file);
317
+ if (ciClusterPatterns.some(p => keyLower.includes(p))) record('clusterName', value, file);
318
+ }
319
+ }
320
+
321
+ // ----- Strategy C: GCP Artifact Registry URLs (any file type) -----
322
+ {
323
+ let m;
324
+ artifactRegistryRegex.lastIndex = 0;
325
+ while ((m = artifactRegistryRegex.exec(content)) !== null) {
326
+ record('region', m[1], file);
327
+ record('projectId', m[2], file);
328
+ }
329
+ }
330
+
331
+ // ----- Strategy D: GKE/EKS resource path references (any file type) -----
332
+ {
333
+ let m;
334
+ gkePathRegex.lastIndex = 0;
335
+ while ((m = gkePathRegex.exec(content)) !== null) {
336
+ record('projectId', m[1], file);
337
+ record('region', m[2], file);
338
+ record('clusterName', m[3], file);
339
+ }
340
+ }
341
+ } catch {
342
+ // Skip unreadable files
343
+ }
344
+ }
345
+
346
+ // ---- Pick the most frequently found value for each field ----
347
+ function pickTop(map) {
348
+ if (map.size === 0) return null;
349
+ let topValue = null;
350
+ let topCount = 0;
351
+ for (const [value, { count }] of map) {
352
+ if (count > topCount) {
353
+ topCount = count;
354
+ topValue = value;
355
+ }
356
+ }
357
+ return topValue;
358
+ }
359
+
360
+ return {
361
+ projectId: pickTop(candidates.projectId),
362
+ region: pickTop(candidates.region),
363
+ clusterName: pickTop(candidates.clusterName)
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Recursively walk multiple root directories collecting files that match
369
+ * any of the given extensions. Respects a skip-list of directory names,
370
+ * a maximum depth, and a maximum total file count.
371
+ *
372
+ * @param {string[]} roots - Absolute paths to start searching from
373
+ * @param {string[]} extensions - File extensions to include (e.g. ['.tf', '.yml'])
374
+ * @param {Set<string>} skipDirs - Directory names to skip
375
+ * @param {number} maxDepth - Maximum recursion depth per root
376
+ * @param {number} maxFiles - Stop collecting after this many files
377
+ * @returns {Promise<string[]>} - Array of absolute file paths
378
+ */
379
+ async function findAllFiles(roots, extensions, skipDirs, maxDepth, maxFiles) {
380
+ const results = [];
381
+ const visited = new Set(); // prevent scanning the same directory twice
382
+
383
+ async function walk(dir, depth) {
384
+ if (depth > maxDepth || results.length >= maxFiles) return;
385
+
386
+ const realDir = resolve(dir);
387
+ if (visited.has(realDir)) return;
388
+ visited.add(realDir);
389
+
390
+ let entries;
391
+ try {
392
+ entries = await fs.readdir(dir, { withFileTypes: true });
393
+ } catch {
394
+ return;
395
+ }
396
+
397
+ for (const entry of entries) {
398
+ if (results.length >= maxFiles) return;
399
+
400
+ const name = entry.name;
401
+ if (name.startsWith('.') && name !== '.gitlab-ci.yml' && !name.startsWith('.gitlab-ci') && !name.startsWith('.github')) {
402
+ // Skip hidden files/dirs except CI config patterns
403
+ if (entry.isDirectory()) continue;
404
+ if (!name.startsWith('.gitlab-ci')) continue;
405
+ }
406
+
407
+ const fullPath = join(dir, name);
408
+
409
+ if (entry.isDirectory()) {
410
+ if (skipDirs.has(name)) continue;
411
+ await walk(fullPath, depth + 1);
412
+ } else {
413
+ // Check if file is too large (skip >1MB)
414
+ try {
415
+ const stat = await fs.stat(fullPath);
416
+ if (stat.size > 1048576) continue;
417
+ } catch {
418
+ continue;
419
+ }
420
+
421
+ // Check extension match
422
+ const matches = extensions.some(ext => name.endsWith(ext));
423
+ if (matches) {
424
+ // Quick binary check: read first 512 bytes for null bytes
425
+ try {
426
+ const fd = await fs.open(fullPath, 'r');
427
+ const buf = Buffer.alloc(512);
428
+ await fd.read(buf, 0, 512, 0);
429
+ await fd.close();
430
+ if (buf.includes(0)) continue; // binary file
431
+ } catch {
432
+ continue;
433
+ }
434
+
435
+ results.push(fullPath);
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ for (const root of roots) {
442
+ if (results.length >= maxFiles) break;
443
+ await walk(root, 0);
444
+ }
445
+
446
+ return results;
447
+ }
448
+
449
+ /**
450
+ * Scan Kubernetes/GitOps manifests for cluster name references.
451
+ * Looks at Flux Kustomization files and cluster-related YAML.
452
+ */
453
+ async function scanKubernetesFiles() {
454
+ const result = { clusterName: null };
455
+
456
+ // Find gitops directory (CWD first, then subdirs with .git)
457
+ const gitopsDir = await findGitopsDir();
458
+ if (!gitopsDir) return result;
459
+
460
+ try {
461
+ // Look for cluster directories under gitops/ (e.g., gitops/clusters/<cluster-name>/)
462
+ // Also check for directories whose name contains "cluster"
463
+ const entries = await fs.readdir(gitopsDir, { withFileTypes: true });
464
+
465
+ for (const entry of entries) {
466
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
467
+
468
+ // Check if directory name itself indicates a cluster
469
+ if (entry.name === 'clusters') {
470
+ try {
471
+ const clusterEntries = await fs.readdir(join(gitopsDir, entry.name), { withFileTypes: true });
472
+ for (const clusterEntry of clusterEntries) {
473
+ if (clusterEntry.isDirectory() && !clusterEntry.name.startsWith('.')) {
474
+ result.clusterName = clusterEntry.name;
475
+ return result;
476
+ }
477
+ }
478
+ } catch {
479
+ // Skip unreadable
480
+ }
481
+ }
482
+
483
+ // Scan YAML files for Flux Kustomization spec.path referencing cluster
484
+ const subDir = join(gitopsDir, entry.name);
485
+ try {
486
+ const yamlFiles = await findFiles(subDir, '.yaml', 3);
487
+ for (const file of yamlFiles) {
488
+ try {
489
+ const content = await fs.readFile(file, 'utf-8');
490
+
491
+ // Flux gotk-sync.yaml: path: ./clusters/<cluster-name>
492
+ const pathMatch = content.match(/path:\s*\.\/clusters\/([^\s/]+)/);
493
+ if (pathMatch && !result.clusterName) {
494
+ result.clusterName = pathMatch[1];
495
+ return result;
496
+ }
497
+
498
+ // Look for cluster name in Kustomization metadata
499
+ if (content.includes('kind: Kustomization') && content.includes('toolkit.fluxcd.io')) {
500
+ const nameMatch = content.match(/name:\s+([^\s]+cluster[^\s]*)/i);
501
+ if (nameMatch && !result.clusterName) {
502
+ result.clusterName = nameMatch[1];
503
+ return result;
504
+ }
505
+ }
506
+ } catch {
507
+ // Skip unreadable files
508
+ }
509
+ }
510
+ } catch {
511
+ // Skip unreadable subdirectories
512
+ }
513
+ }
514
+ } catch {
515
+ // Skip if gitops dir not readable
516
+ }
517
+
518
+ return result;
519
+ }
520
+
521
+ /**
522
+ * Detect project identity: project name, git platform, and CI/CD platform.
523
+ * Uses only local file scanning (no external CLI calls).
524
+ */
525
+ async function scanProjectIdentity() {
526
+ const result = { projectName: null, gitPlatform: null, ciPlatform: null };
527
+
528
+ // Collect dirs to check: CWD first, then subdirs with .git
529
+ const dirsToCheck = [CWD];
530
+ const repos = await findSubdirRepos();
531
+ for (const repo of repos) {
532
+ dirsToCheck.push(repo.path);
533
+ }
534
+
535
+ // 1. Project name from package.json or directory name
536
+ for (const dir of dirsToCheck) {
537
+ if (result.projectName) break;
538
+ try {
539
+ const pkgPath = join(dir, 'package.json');
540
+ if (existsSync(pkgPath)) {
541
+ const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf-8'));
542
+ if (pkg.name && !pkg.name.startsWith('@') && pkg.name !== 'my-project') {
543
+ result.projectName = pkg.name;
544
+ } else if (pkg.name && pkg.name.startsWith('@')) {
545
+ // Scoped package: extract the package part after /
546
+ const parts = pkg.name.split('/');
547
+ if (parts.length > 1 && parts[1] !== 'my-project') {
548
+ result.projectName = parts[1];
549
+ }
550
+ }
551
+ }
552
+ } catch {
553
+ // Skip
554
+ }
555
+ }
556
+
557
+ if (!result.projectName) {
558
+ result.projectName = basename(CWD);
559
+ }
560
+
561
+ // 2. Git platform from remote URLs
562
+ for (const dir of dirsToCheck) {
563
+ if (result.gitPlatform) break;
564
+ try {
565
+ if (existsSync(join(dir, '.git'))) {
566
+ const remote = await getGitRemote(dir);
567
+ if (remote) {
568
+ if (remote.includes('gitlab.com') || remote.includes('gitlab.')) result.gitPlatform = 'gitlab';
569
+ else if (remote.includes('github.com')) result.gitPlatform = 'github';
570
+ else if (remote.includes('bitbucket.org') || remote.includes('bitbucket.')) result.gitPlatform = 'bitbucket';
571
+ }
572
+ }
573
+ } catch {
574
+ // Skip
575
+ }
576
+ }
577
+
578
+ // 3. CI/CD platform from config files
579
+ for (const dir of dirsToCheck) {
580
+ if (result.ciPlatform) break;
581
+ try {
582
+ if (existsSync(join(dir, '.gitlab-ci.yml'))) {
583
+ result.ciPlatform = 'gitlab-ci';
584
+ } else if (existsSync(join(dir, '.github', 'workflows'))) {
585
+ result.ciPlatform = 'github-actions';
586
+ } else if (existsSync(join(dir, 'Jenkinsfile'))) {
587
+ result.ciPlatform = 'jenkins';
588
+ } else if (existsSync(join(dir, '.circleci'))) {
589
+ result.ciPlatform = 'circleci';
590
+ }
591
+ } catch {
592
+ // Skip
593
+ }
594
+ }
595
+
596
+ return result;
597
+ }
598
+
188
599
  /**
189
600
  * Extract the content of a provider block from HCL/TF content.
190
601
  * Handles nested braces to find the correct closing brace.
@@ -279,13 +690,16 @@ async function scanClaudeCode() {
279
690
  */
280
691
  function buildConfig(scan, args) {
281
692
  return {
282
- gitops: args.gitops || process.env.CLAUDE_GITOPS_DIR || scan.dirs.gitops || './gitops',
283
- terraform: args.terraform || process.env.CLAUDE_TERRAFORM_DIR || scan.dirs.terraform || './terraform',
284
- appServices: args.appServices || process.env.CLAUDE_APP_SERVICES_DIR || scan.dirs.appServices || './app-services',
693
+ gitops: args.gitops || process.env.CLAUDE_GITOPS_DIR || scan.dirs.gitops || '',
694
+ terraform: args.terraform || process.env.CLAUDE_TERRAFORM_DIR || scan.dirs.terraform || '',
695
+ appServices: args.appServices || process.env.CLAUDE_APP_SERVICES_DIR || scan.dirs.appServices || '',
285
696
  cloudProvider: scan.cloud.provider || 'gcp',
286
- projectId: args.projectId || process.env.CLAUDE_PROJECT_ID || scan.cloud.projectId || '',
287
- region: args.region || process.env.CLAUDE_REGION || scan.cloud.region || '',
288
- clusterName: args.cluster || process.env.CLAUDE_CLUSTER_NAME || '',
697
+ projectId: args.projectId || process.env.CLAUDE_PROJECT_ID || scan.cloud.projectId || scan.tfvars.projectId || '',
698
+ region: args.region || process.env.CLAUDE_REGION || scan.cloud.region || scan.tfvars.region || '',
699
+ clusterName: args.cluster || process.env.CLAUDE_CLUSTER_NAME || scan.tfvars.clusterName || scan.k8s.clusterName || '',
700
+ projectName: scan.identity.projectName || basename(CWD),
701
+ gitPlatform: scan.identity.gitPlatform || null,
702
+ ciPlatform: scan.identity.ciPlatform || null,
289
703
  projectContextRepo: args.projectContextRepo || process.env.CLAUDE_PROJECT_CONTEXT_REPO || '',
290
704
  claudeCode: scan.claudeCode,
291
705
  gitRemotes: scan.gitRemotes
@@ -312,9 +726,9 @@ async function displayAndConfirm(config) {
312
726
 
313
727
  // Paths
314
728
  console.log(chalk.white(' Paths'));
315
- printField('GitOps', config.gitops, existsSync(resolve(CWD, config.gitops)));
316
- printField('Terraform', config.terraform, existsSync(resolve(CWD, config.terraform)));
317
- printField('App Services', config.appServices, existsSync(resolve(CWD, config.appServices)));
729
+ printField('GitOps', config.gitops || null, config.gitops ? existsSync(resolve(CWD, config.gitops)) : false);
730
+ printField('Terraform', config.terraform || null, config.terraform ? existsSync(resolve(CWD, config.terraform)) : false);
731
+ printField('App Services', config.appServices || null, config.appServices ? existsSync(resolve(CWD, config.appServices)) : false);
318
732
 
319
733
  // Cloud
320
734
  console.log(chalk.white('\n Cloud'));
@@ -323,6 +737,12 @@ async function displayAndConfirm(config) {
323
737
  printField('Region', config.region || null, !!config.region);
324
738
  printField('Cluster', config.clusterName || null, !!config.clusterName);
325
739
 
740
+ // Identity
741
+ console.log(chalk.white('\n Identity'));
742
+ printField('Project Name', config.projectName || null, !!config.projectName);
743
+ printField('Git Platform', config.gitPlatform || null, !!config.gitPlatform);
744
+ printField('CI/CD', config.ciPlatform || null, !!config.ciPlatform);
745
+
326
746
  // Git remotes (informational)
327
747
  if (config.gitRemotes.length > 0) {
328
748
  console.log(chalk.white('\n Git Repositories'));
@@ -346,6 +766,9 @@ async function displayAndConfirm(config) {
346
766
 
347
767
  // Identify gaps (items that need user input)
348
768
  const gaps = [];
769
+ if (!config.gitops) gaps.push('gitops');
770
+ if (!config.terraform) gaps.push('terraform');
771
+ if (!config.appServices) gaps.push('appServices');
349
772
  if (!config.projectId) gaps.push('projectId');
350
773
  if (!config.region) gaps.push('region');
351
774
  if (!config.clusterName) gaps.push('clusterName');
@@ -375,13 +798,41 @@ async function confirmOrEdit(config, gaps) {
375
798
 
376
799
  const gapQuestions = [];
377
800
 
801
+ if (gaps.includes('gitops')) {
802
+ gapQuestions.push({
803
+ type: 'text',
804
+ name: 'gitops',
805
+ message: ' GitOps directory (Enter to skip):',
806
+ initial: ''
807
+ });
808
+ }
809
+
810
+ if (gaps.includes('terraform')) {
811
+ gapQuestions.push({
812
+ type: 'text',
813
+ name: 'terraform',
814
+ message: ' Terraform directory (Enter to skip):',
815
+ initial: ''
816
+ });
817
+ }
818
+
819
+ if (gaps.includes('appServices')) {
820
+ gapQuestions.push({
821
+ type: 'text',
822
+ name: 'appServices',
823
+ message: ' App Services directory (Enter to skip):',
824
+ initial: ''
825
+ });
826
+ }
827
+
378
828
  if (gaps.includes('projectId')) {
379
829
  gapQuestions.push({
380
830
  type: 'text',
381
831
  name: 'projectId',
382
- message: config.cloudProvider === 'aws' ? ' AWS Account ID:' : ' Cloud Project ID:',
383
- initial: '',
384
- validate: v => v.trim().length > 0 || 'Required for agent operations'
832
+ message: config.cloudProvider === 'aws'
833
+ ? ' AWS Account ID (Enter to skip):'
834
+ : ' Cloud Project ID (Enter to skip):',
835
+ initial: ''
385
836
  });
386
837
  }
387
838
 
@@ -389,7 +840,7 @@ async function confirmOrEdit(config, gaps) {
389
840
  gapQuestions.push({
390
841
  type: 'text',
391
842
  name: 'region',
392
- message: ' Primary Region:',
843
+ message: ' Primary Region (Enter to skip):',
393
844
  initial: config.cloudProvider === 'gcp' ? 'us-central1' : 'us-east-1'
394
845
  });
395
846
  }
@@ -398,7 +849,7 @@ async function confirmOrEdit(config, gaps) {
398
849
  gapQuestions.push({
399
850
  type: 'text',
400
851
  name: 'clusterName',
401
- message: ' Cluster Name (empty to skip):',
852
+ message: ' Cluster Name (Enter to skip):',
402
853
  initial: ''
403
854
  });
404
855
  }
@@ -408,6 +859,9 @@ async function confirmOrEdit(config, gaps) {
408
859
  });
409
860
 
410
861
  // Merge gap answers into config
862
+ if (gapAnswers.gitops) config.gitops = gapAnswers.gitops;
863
+ if (gapAnswers.terraform) config.terraform = gapAnswers.terraform;
864
+ if (gapAnswers.appServices) config.appServices = gapAnswers.appServices;
411
865
  if (gapAnswers.projectId) config.projectId = gapAnswers.projectId;
412
866
  if (gapAnswers.region) config.region = gapAnswers.region;
413
867
  if (gapAnswers.clusterName !== undefined) config.clusterName = gapAnswers.clusterName;
@@ -650,6 +1104,12 @@ async function generateProjectContext(config) {
650
1104
  if (config.cloudProvider === 'aws' || config.cloudProvider === 'multi-cloud') {
651
1105
  projectDetails.account_id = config.projectId;
652
1106
  }
1107
+ if (config.gitPlatform) {
1108
+ projectDetails.git_platform = config.gitPlatform;
1109
+ }
1110
+ if (config.ciPlatform) {
1111
+ projectDetails.ci_platform = config.ciPlatform;
1112
+ }
653
1113
 
654
1114
  // Build provider credentials
655
1115
  const providerCredentials = {};
@@ -741,6 +1201,8 @@ async function ensureProjectDirs(config) {
741
1201
  ];
742
1202
 
743
1203
  for (const { path: dirPath, name } of dirs) {
1204
+ // Skip dirs where path is empty/falsy — only create when actually detected or user-provided
1205
+ if (!dirPath) continue;
744
1206
  const absPath = isAbsolute(dirPath) ? dirPath : resolve(CWD, dirPath);
745
1207
  if (!existsSync(absPath)) {
746
1208
  await fs.mkdir(absPath, { recursive: true });
@@ -881,6 +1343,85 @@ async function checkHooks() {
881
1343
  // Utility functions
882
1344
  // ============================================================================
883
1345
 
1346
+ /**
1347
+ * Find subdirectories of CWD that contain .git (they are repos).
1348
+ * Returns array of {name, path} where path is the absolute path.
1349
+ */
1350
+ async function findSubdirRepos() {
1351
+ const repos = [];
1352
+ try {
1353
+ const entries = await fs.readdir(CWD, { withFileTypes: true });
1354
+ for (const entry of entries) {
1355
+ if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
1356
+ const subDir = join(CWD, entry.name);
1357
+ if (existsSync(join(subDir, '.git'))) {
1358
+ repos.push({ name: entry.name, path: subDir });
1359
+ }
1360
+ }
1361
+ } catch { /* skip */ }
1362
+ return repos;
1363
+ }
1364
+
1365
+ /**
1366
+ * Find the terraform directory, checking CWD then subdirectories with .git.
1367
+ * Returns absolute path or null.
1368
+ */
1369
+ async function findTerraformDir() {
1370
+ const candidates = ['terraform', 'tf', 'infrastructure', 'iac', 'infra'];
1371
+
1372
+ // Check CWD first
1373
+ for (const c of candidates) {
1374
+ const p = join(CWD, c);
1375
+ if (existsSync(p)) return p;
1376
+ }
1377
+
1378
+ // Check subdirectories that have .git (repos)
1379
+ const repos = await findSubdirRepos();
1380
+ for (const repo of repos) {
1381
+ for (const c of candidates) {
1382
+ const p = join(repo.path, c);
1383
+ if (existsSync(p)) return p;
1384
+ }
1385
+ }
1386
+
1387
+ return null;
1388
+ }
1389
+
1390
+ /**
1391
+ * Find the gitops directory, checking CWD then subdirectories with .git.
1392
+ * Returns absolute path or null.
1393
+ */
1394
+ async function findGitopsDir() {
1395
+ const candidates = ['gitops', 'k8s', 'kubernetes', 'manifests', 'deployments'];
1396
+
1397
+ // Check CWD first
1398
+ for (const c of candidates) {
1399
+ const p = join(CWD, c);
1400
+ if (existsSync(p)) return p;
1401
+ }
1402
+
1403
+ // Check subdirectories that have .git (repos)
1404
+ const repos = await findSubdirRepos();
1405
+ for (const repo of repos) {
1406
+ for (const c of candidates) {
1407
+ const p = join(repo.path, c);
1408
+ if (existsSync(p)) return p;
1409
+ }
1410
+ }
1411
+
1412
+ return null;
1413
+ }
1414
+
1415
+ /**
1416
+ * Convert an absolute path to a CWD-relative path prefixed with ./
1417
+ */
1418
+ function toRelativePath(absPath) {
1419
+ if (!absPath) return null;
1420
+ const rel = relative(CWD, absPath);
1421
+ if (!rel) return '.';
1422
+ return rel.startsWith('.') ? rel : `./${rel}`;
1423
+ }
1424
+
884
1425
  async function getGitRemote(dir) {
885
1426
  try {
886
1427
  const { stdout } = await execAsync('git remote get-url origin', { cwd: dir, timeout: 5000 });
@@ -913,6 +1454,7 @@ async function findFiles(dir, extension, maxDepth, currentDepth = 0) {
913
1454
  }
914
1455
 
915
1456
  async function detectGitopsPlatform(gitopsPath) {
1457
+ if (!gitopsPath) return 'flux';
916
1458
  const absPath = isAbsolute(gitopsPath) ? gitopsPath : resolve(CWD, gitopsPath);
917
1459
  if (!existsSync(absPath)) return 'flux';
918
1460
 
@@ -980,13 +1522,16 @@ async function main() {
980
1522
  if (args.nonInteractive) {
981
1523
  // Non-interactive: show what was detected and proceed
982
1524
  console.log(chalk.cyan('\n Configuration (auto-detected + overrides):\n'));
983
- console.log(chalk.gray(` GitOps: ${config.gitops}`));
984
- console.log(chalk.gray(` Terraform: ${config.terraform}`));
985
- console.log(chalk.gray(` App Services: ${config.appServices}`));
1525
+ console.log(chalk.gray(` GitOps: ${config.gitops || '(not detected)'}`));
1526
+ console.log(chalk.gray(` Terraform: ${config.terraform || '(not detected)'}`));
1527
+ console.log(chalk.gray(` App Services: ${config.appServices || '(not detected)'}`));
986
1528
  console.log(chalk.gray(` Cloud: ${config.cloudProvider?.toUpperCase()}`));
987
1529
  console.log(chalk.gray(` Project ID: ${config.projectId || '(none)'}`));
988
1530
  console.log(chalk.gray(` Region: ${config.region || '(none)'}`));
989
- console.log(chalk.gray(` Cluster: ${config.clusterName || '(none)'}\n`));
1531
+ console.log(chalk.gray(` Cluster: ${config.clusterName || '(none)'}`));
1532
+ console.log(chalk.gray(` Project Name: ${config.projectName || '(none)'}`));
1533
+ console.log(chalk.gray(` Git Platform: ${config.gitPlatform || '(none)'}`));
1534
+ console.log(chalk.gray(` CI/CD: ${config.ciPlatform || '(none)'}\n`));
990
1535
  } else {
991
1536
  // Phase 2: Display + Confirm
992
1537
  const { gaps } = await displayAndConfirm(config);