@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 +577 -32
- package/hooks/modules/audit/event_detector.py +0 -26
- package/hooks/pre_tool_use.py +32 -0
- package/hooks/subagent_stop.py +38 -130
- package/package.json +1 -1
- package/templates/CLAUDE.template.md +22 -48
- package/tests/hooks/test_subagent_stop_discovery.py +64 -99
- package/tests/integration/test_subagent_stop_e2e.py +10 -8
- package/tests/system/test_schema_compatibility.py +29 -41
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
|
|
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 || '
|
|
283
|
-
terraform: args.terraform || process.env.CLAUDE_TERRAFORM_DIR || scan.dirs.terraform || '
|
|
284
|
-
appServices: args.appServices || process.env.CLAUDE_APP_SERVICES_DIR || scan.dirs.appServices || '
|
|
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'
|
|
383
|
-
|
|
384
|
-
|
|
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 (
|
|
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)'}
|
|
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);
|