@tamyla/clodo-framework 4.5.0 → 4.6.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/CHANGELOG.md +28 -0
- package/README.md +157 -13
- package/dist/cli/clodo-service.js +13 -0
- package/dist/cli/commands/config-schema.js +144 -0
- package/dist/cli/commands/create.js +18 -1
- package/dist/cli/commands/deploy.js +61 -2
- package/dist/cli/commands/doctor.js +124 -0
- package/dist/cli/commands/secrets.js +258 -0
- package/dist/cli/security-cli.js +1 -1
- package/dist/index.js +3 -1
- package/dist/security/SecretsManager.js +398 -0
- package/dist/security/index.js +2 -0
- package/dist/service-management/ConfirmationEngine.js +4 -1
- package/dist/service-management/generators/utils/ServiceManifestGenerator.js +5 -1
- package/dist/service-management/handlers/ConfirmationHandler.js +1 -1
- package/dist/service-management/handlers/ValidationHandler.js +696 -0
- package/dist/validation/ConfigSchemaValidator.js +503 -0
- package/dist/validation/configSchemas.js +236 -0
- package/dist/validation/index.js +6 -2
- package/docs/00_START_HERE.md +26 -338
- package/package.json +1 -1
|
@@ -7,6 +7,8 @@ import fs from 'fs/promises';
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { FrameworkConfig } from '../../utils/framework-config.js';
|
|
9
9
|
import { ConfigurationValidator } from '../../security/ConfigurationValidator.js';
|
|
10
|
+
import { SecretsManager } from '../../security/SecretsManager.js';
|
|
11
|
+
import { ConfigSchemaValidator } from '../../validation/ConfigSchemaValidator.js';
|
|
10
12
|
export class ValidationHandler {
|
|
11
13
|
constructor(options = {}) {
|
|
12
14
|
this.strict = options.strict || false;
|
|
@@ -62,6 +64,38 @@ export class ValidationHandler {
|
|
|
62
64
|
return this.validationConfig;
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Check if a version is sufficient (greater than or equal to minimum)
|
|
69
|
+
*/
|
|
70
|
+
isVersionSufficient(currentVersion, minVersion) {
|
|
71
|
+
const current = currentVersion.replace(/^v/, '').split('.').map(Number);
|
|
72
|
+
const min = minVersion.split('.').map(Number);
|
|
73
|
+
for (let i = 0; i < Math.max(current.length, min.length); i++) {
|
|
74
|
+
const c = current[i] || 0;
|
|
75
|
+
const m = min[i] || 0;
|
|
76
|
+
if (c > m) return true;
|
|
77
|
+
if (c < m) return false;
|
|
78
|
+
}
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate fix suggestions from validation issues
|
|
84
|
+
*/
|
|
85
|
+
generateFixSuggestions(issues) {
|
|
86
|
+
const suggestions = [];
|
|
87
|
+
issues.forEach(issue => {
|
|
88
|
+
if (issue.includes('Missing required field: main')) {
|
|
89
|
+
suggestions.push('Add main field to package.json');
|
|
90
|
+
} else if (issue.includes('Missing required dependency:')) {
|
|
91
|
+
suggestions.push(issue); // Pass the full issue text for dependency extraction
|
|
92
|
+
} else if (issue.includes('Should use "type": "module"')) {
|
|
93
|
+
suggestions.push('Set package.json type to module');
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return suggestions;
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
/**
|
|
66
100
|
* Validate complete service configuration
|
|
67
101
|
*/
|
|
@@ -284,4 +318,666 @@ export class ValidationHandler {
|
|
|
284
318
|
setStrict(enabled) {
|
|
285
319
|
this.strict = enabled;
|
|
286
320
|
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Run comprehensive doctor checks for preflight diagnostics
|
|
324
|
+
* @param {Object} options - Options for doctor run
|
|
325
|
+
* @param {boolean} options.json - Output in JSON format
|
|
326
|
+
* @param {boolean} options.fix - Attempt to fix issues
|
|
327
|
+
* @param {boolean} options.strict - Strict mode (fail on warnings)
|
|
328
|
+
* @param {string} options.servicePath - Path to service directory
|
|
329
|
+
* @returns {Object} Doctor results
|
|
330
|
+
*/
|
|
331
|
+
async runDoctor(options = {}) {
|
|
332
|
+
const {
|
|
333
|
+
json = false,
|
|
334
|
+
fix = false,
|
|
335
|
+
strict = this.strict,
|
|
336
|
+
servicePath = process.cwd()
|
|
337
|
+
} = options;
|
|
338
|
+
const results = {
|
|
339
|
+
timestamp: new Date().toISOString(),
|
|
340
|
+
servicePath,
|
|
341
|
+
checks: [],
|
|
342
|
+
summary: {
|
|
343
|
+
total: 0,
|
|
344
|
+
passed: 0,
|
|
345
|
+
warnings: 0,
|
|
346
|
+
errors: 0,
|
|
347
|
+
critical: 0
|
|
348
|
+
},
|
|
349
|
+
fixSuggestions: [],
|
|
350
|
+
fixesApplied: [],
|
|
351
|
+
exitCode: 0
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// Load validation config
|
|
355
|
+
const config = this.loadValidationConfig(servicePath);
|
|
356
|
+
|
|
357
|
+
// Run environment checks
|
|
358
|
+
const envCheck = await this.checkEnvironment();
|
|
359
|
+
results.checks.push(envCheck);
|
|
360
|
+
this.updateSummary(results, envCheck);
|
|
361
|
+
|
|
362
|
+
// Run config presence checks
|
|
363
|
+
const configCheck = await this.checkConfigPresence(servicePath, config);
|
|
364
|
+
results.checks.push(configCheck);
|
|
365
|
+
this.updateSummary(results, configCheck);
|
|
366
|
+
|
|
367
|
+
// Run service validation
|
|
368
|
+
const validationCheck = await this.validateService(servicePath);
|
|
369
|
+
const fixSuggestions = this.generateFixSuggestions(validationCheck.issues || []);
|
|
370
|
+
results.checks.push({
|
|
371
|
+
name: 'service-validation',
|
|
372
|
+
status: validationCheck.valid ? 'passed' : 'failed',
|
|
373
|
+
severity: validationCheck.valid ? 'info' : 'error',
|
|
374
|
+
message: validationCheck.valid ? 'Service validation passed' : 'Service validation failed',
|
|
375
|
+
details: validationCheck.issues || [],
|
|
376
|
+
fixSuggestions
|
|
377
|
+
});
|
|
378
|
+
this.updateSummary(results, results.checks[results.checks.length - 1]);
|
|
379
|
+
|
|
380
|
+
// Run token scope check
|
|
381
|
+
const tokenCheck = await this.checkTokenScopes();
|
|
382
|
+
results.checks.push(tokenCheck);
|
|
383
|
+
this.updateSummary(results, tokenCheck);
|
|
384
|
+
|
|
385
|
+
// Run secrets baseline check
|
|
386
|
+
const secretsCheck = await this.checkSecretsBaseline(servicePath);
|
|
387
|
+
results.checks.push(secretsCheck);
|
|
388
|
+
this.updateSummary(results, secretsCheck);
|
|
389
|
+
|
|
390
|
+
// Run config schema validation check
|
|
391
|
+
const configSchemaCheck = await this.checkConfigSchemas(servicePath);
|
|
392
|
+
results.checks.push(configSchemaCheck);
|
|
393
|
+
this.updateSummary(results, configSchemaCheck);
|
|
394
|
+
|
|
395
|
+
// Apply fixes if requested
|
|
396
|
+
if (fix && results.summary.errors > 0) {
|
|
397
|
+
console.log('🔧 Attempting to fix detected issues...');
|
|
398
|
+
const fixesApplied = await this.applyFixes(results, servicePath);
|
|
399
|
+
results.fixesApplied = fixesApplied;
|
|
400
|
+
|
|
401
|
+
// Re-run checks after fixes
|
|
402
|
+
if (fixesApplied.length > 0) {
|
|
403
|
+
console.log(`✅ Applied ${fixesApplied.length} fixes. Re-running checks...`);
|
|
404
|
+
return this.runDoctor({
|
|
405
|
+
...options,
|
|
406
|
+
fix: false
|
|
407
|
+
}); // Re-run without fix to see results
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Determine exit code
|
|
412
|
+
results.exitCode = results.summary.errors > 0 || strict && results.summary.warnings > 0 ? 1 : 0;
|
|
413
|
+
|
|
414
|
+
// Collect fix suggestions
|
|
415
|
+
results.checks.forEach(check => {
|
|
416
|
+
if (check.fixSuggestions && check.fixSuggestions.length > 0) {
|
|
417
|
+
results.fixSuggestions.push(...check.fixSuggestions);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
return results;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Update summary counts based on check result
|
|
425
|
+
*/
|
|
426
|
+
updateSummary(results, check) {
|
|
427
|
+
results.summary.total++;
|
|
428
|
+
if (check.status === 'passed') {
|
|
429
|
+
results.summary.passed++;
|
|
430
|
+
} else if (check.severity === 'warning') {
|
|
431
|
+
results.summary.warnings++;
|
|
432
|
+
} else if (check.severity === 'error') {
|
|
433
|
+
results.summary.errors++;
|
|
434
|
+
} else if (check.severity === 'critical') {
|
|
435
|
+
results.summary.critical++;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Check environment prerequisites
|
|
441
|
+
*/
|
|
442
|
+
async checkEnvironment() {
|
|
443
|
+
const check = {
|
|
444
|
+
name: 'environment',
|
|
445
|
+
status: 'passed',
|
|
446
|
+
severity: 'info',
|
|
447
|
+
message: 'Environment checks passed',
|
|
448
|
+
details: [],
|
|
449
|
+
fixSuggestions: []
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Check Node.js version
|
|
453
|
+
const nodeVersion = process.version;
|
|
454
|
+
const minVersion = '18.0.0';
|
|
455
|
+
if (!this.isVersionSufficient(nodeVersion, minVersion)) {
|
|
456
|
+
check.status = 'failed';
|
|
457
|
+
check.severity = 'error';
|
|
458
|
+
check.details.push(`Node.js version ${nodeVersion} is below minimum ${minVersion}`);
|
|
459
|
+
check.fixSuggestions.push('Upgrade Node.js to version 18 or higher');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Check if wrangler is available
|
|
463
|
+
try {
|
|
464
|
+
const {
|
|
465
|
+
execSync
|
|
466
|
+
} = await import('child_process');
|
|
467
|
+
execSync('wrangler --version', {
|
|
468
|
+
stdio: 'pipe'
|
|
469
|
+
});
|
|
470
|
+
check.details.push('Wrangler CLI is available');
|
|
471
|
+
} catch (error) {
|
|
472
|
+
check.status = 'failed';
|
|
473
|
+
check.severity = 'error';
|
|
474
|
+
check.details.push('Wrangler CLI is not installed or not in PATH');
|
|
475
|
+
check.fixSuggestions.push('Install Wrangler CLI: npm install -g wrangler');
|
|
476
|
+
}
|
|
477
|
+
return check;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Check config file presence
|
|
482
|
+
*/
|
|
483
|
+
async checkConfigPresence(servicePath, config) {
|
|
484
|
+
const check = {
|
|
485
|
+
name: 'config-presence',
|
|
486
|
+
status: 'passed',
|
|
487
|
+
severity: 'info',
|
|
488
|
+
message: 'Configuration files present',
|
|
489
|
+
details: [],
|
|
490
|
+
fixSuggestions: []
|
|
491
|
+
};
|
|
492
|
+
const requiredFiles = config.requiredFiles || [];
|
|
493
|
+
for (const file of requiredFiles) {
|
|
494
|
+
try {
|
|
495
|
+
await fs.access(path.join(servicePath, file));
|
|
496
|
+
check.details.push(`✓ ${file}`);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
check.status = 'failed';
|
|
499
|
+
check.severity = 'error';
|
|
500
|
+
check.details.push(`✗ ${file} is missing`);
|
|
501
|
+
check.fixSuggestions.push(`Create ${file} or ensure it exists`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return check;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Check Cloudflare token scopes
|
|
509
|
+
*/
|
|
510
|
+
async checkTokenScopes() {
|
|
511
|
+
const check = {
|
|
512
|
+
name: 'token-scopes',
|
|
513
|
+
status: 'passed',
|
|
514
|
+
severity: 'info',
|
|
515
|
+
message: 'Cloudflare token scopes validated',
|
|
516
|
+
details: [],
|
|
517
|
+
fixSuggestions: []
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// Get Cloudflare token from environment
|
|
521
|
+
const token = process.env.CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN;
|
|
522
|
+
if (!token) {
|
|
523
|
+
check.status = 'failed';
|
|
524
|
+
check.severity = 'error';
|
|
525
|
+
check.details.push('No Cloudflare API token found in environment variables');
|
|
526
|
+
check.fixSuggestions.push('Set CLOUDFLARE_API_TOKEN or CF_API_TOKEN environment variable');
|
|
527
|
+
check.fixSuggestions.push('Generate a token at: https://dash.cloudflare.com/profile/api-tokens');
|
|
528
|
+
return check;
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
// Check token permissions by making API calls
|
|
532
|
+
const permissions = await this.validateCloudflareToken(token);
|
|
533
|
+
if (!permissions.hasWorkersEdit) {
|
|
534
|
+
check.status = 'failed';
|
|
535
|
+
check.severity = 'error';
|
|
536
|
+
check.details.push('Token missing required "Account:Workers:Edit" permission');
|
|
537
|
+
check.fixSuggestions.push('Add "Account:Workers:Edit" permission to your API token');
|
|
538
|
+
} else {
|
|
539
|
+
check.details.push('✓ Account:Workers:Edit permission present');
|
|
540
|
+
}
|
|
541
|
+
if (!permissions.hasWorkersRead) {
|
|
542
|
+
check.status = 'warning';
|
|
543
|
+
check.severity = 'warning';
|
|
544
|
+
check.details.push('Token missing "Account:Workers:Read" permission (recommended)');
|
|
545
|
+
check.fixSuggestions.push('Consider adding "Account:Workers:Read" permission for better diagnostics');
|
|
546
|
+
} else {
|
|
547
|
+
check.details.push('✓ Account:Workers:Read permission present');
|
|
548
|
+
}
|
|
549
|
+
if (!permissions.hasZoneRead) {
|
|
550
|
+
check.status = 'warning';
|
|
551
|
+
check.severity = 'warning';
|
|
552
|
+
check.details.push('Token missing "Zone:Read" permission (recommended for domain validation)');
|
|
553
|
+
check.fixSuggestions.push('Consider adding "Zone:Read" permission for domain validation');
|
|
554
|
+
} else {
|
|
555
|
+
check.details.push('✓ Zone:Read permission present');
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
check.status = 'failed';
|
|
559
|
+
check.severity = 'error';
|
|
560
|
+
check.details.push(`Token validation failed: ${error.message}`);
|
|
561
|
+
check.fixSuggestions.push('Verify your API token is valid and has network access');
|
|
562
|
+
check.fixSuggestions.push('Check token permissions at: https://dash.cloudflare.com/profile/api-tokens');
|
|
563
|
+
}
|
|
564
|
+
return check;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Validate Cloudflare token by testing API access
|
|
569
|
+
*/
|
|
570
|
+
async validateCloudflareToken(token) {
|
|
571
|
+
const permissions = {
|
|
572
|
+
hasWorkersEdit: false,
|
|
573
|
+
hasWorkersRead: false,
|
|
574
|
+
hasZoneRead: false
|
|
575
|
+
};
|
|
576
|
+
try {
|
|
577
|
+
// Test Workers:Edit permission by trying to list workers
|
|
578
|
+
const workersResponse = await fetch('https://api.cloudflare.com/client/v4/accounts', {
|
|
579
|
+
method: 'GET',
|
|
580
|
+
headers: {
|
|
581
|
+
'Authorization': `Bearer ${token}`,
|
|
582
|
+
'Content-Type': 'application/json'
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
if (workersResponse.ok) {
|
|
586
|
+
const data = await workersResponse.json();
|
|
587
|
+
if (data.success && data.result && data.result.length > 0) {
|
|
588
|
+
permissions.hasWorkersRead = true;
|
|
589
|
+
permissions.hasWorkersEdit = true; // If we can read accounts, we likely can edit workers
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Test Zone:Read permission
|
|
594
|
+
const zonesResponse = await fetch('https://api.cloudflare.com/client/v4/zones', {
|
|
595
|
+
method: 'GET',
|
|
596
|
+
headers: {
|
|
597
|
+
'Authorization': `Bearer ${token}`,
|
|
598
|
+
'Content-Type': 'application/json'
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
if (zonesResponse.ok) {
|
|
602
|
+
permissions.hasZoneRead = true;
|
|
603
|
+
}
|
|
604
|
+
} catch (error) {
|
|
605
|
+
// If network fails, we can't validate but don't fail completely
|
|
606
|
+
console.warn('Network error during token validation:', error.message);
|
|
607
|
+
}
|
|
608
|
+
return permissions;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Check secrets baseline
|
|
613
|
+
*/
|
|
614
|
+
async checkSecretsBaseline(servicePath) {
|
|
615
|
+
const check = {
|
|
616
|
+
name: 'secrets-baseline',
|
|
617
|
+
status: 'passed',
|
|
618
|
+
severity: 'info',
|
|
619
|
+
message: 'Secrets baseline validation passed',
|
|
620
|
+
details: [],
|
|
621
|
+
fixSuggestions: []
|
|
622
|
+
};
|
|
623
|
+
try {
|
|
624
|
+
// Scan for potential secrets
|
|
625
|
+
const secretsFound = await this.scanForSecrets(servicePath);
|
|
626
|
+
if (secretsFound.length > 0) {
|
|
627
|
+
// Check against baseline
|
|
628
|
+
const baselineSecrets = await this.loadSecretsBaseline(servicePath);
|
|
629
|
+
const newSecrets = secretsFound.filter(secret => !baselineSecrets.some(baseline => baseline.file === secret.file && baseline.line === secret.line && baseline.pattern === secret.pattern));
|
|
630
|
+
if (newSecrets.length > 0) {
|
|
631
|
+
check.status = 'failed';
|
|
632
|
+
check.severity = 'critical';
|
|
633
|
+
check.message = `Found ${newSecrets.length} potential secrets not in baseline`;
|
|
634
|
+
check.details.push(...newSecrets.map(secret => `${secret.file}:${secret.line} - ${secret.pattern}: ${secret.match}`));
|
|
635
|
+
check.fixSuggestions.push('Review the secrets above and ensure they are not sensitive');
|
|
636
|
+
check.fixSuggestions.push('If safe, add to .secrets.baseline file');
|
|
637
|
+
check.fixSuggestions.push('Run: clodo secrets baseline update');
|
|
638
|
+
} else {
|
|
639
|
+
check.details.push(`✓ All ${secretsFound.length} secrets are in baseline`);
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
check.details.push('✓ No potential secrets found');
|
|
643
|
+
}
|
|
644
|
+
} catch (error) {
|
|
645
|
+
check.status = 'warning';
|
|
646
|
+
check.severity = 'warning';
|
|
647
|
+
check.details.push(`Secrets scanning failed: ${error.message}`);
|
|
648
|
+
check.fixSuggestions.push('Ensure file permissions allow reading source files');
|
|
649
|
+
}
|
|
650
|
+
return check;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Scan for potential secrets in the codebase
|
|
655
|
+
* Delegates to SecretsManager for consistent scanning logic
|
|
656
|
+
*/
|
|
657
|
+
async scanForSecrets(servicePath) {
|
|
658
|
+
const mgr = new SecretsManager();
|
|
659
|
+
return mgr.scan(servicePath);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Get list of files to scan for secrets
|
|
664
|
+
* Delegates to SecretsManager
|
|
665
|
+
*/
|
|
666
|
+
async getFilesToScan(servicePath) {
|
|
667
|
+
const mgr = new SecretsManager();
|
|
668
|
+
return mgr.getFilesToScan(servicePath);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Load secrets baseline file
|
|
673
|
+
* Delegates to SecretsManager
|
|
674
|
+
*/
|
|
675
|
+
async loadSecretsBaseline(servicePath) {
|
|
676
|
+
const mgr = new SecretsManager();
|
|
677
|
+
return mgr.loadBaseline(servicePath);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Check config files in the service directory against their schemas
|
|
682
|
+
* Validates any config JSON files found (clodo-*.json pattern)
|
|
683
|
+
*/
|
|
684
|
+
async checkConfigSchemas(servicePath) {
|
|
685
|
+
const check = {
|
|
686
|
+
name: 'config-schemas',
|
|
687
|
+
status: 'passed',
|
|
688
|
+
severity: 'info',
|
|
689
|
+
message: 'Config schema validation passed',
|
|
690
|
+
details: [],
|
|
691
|
+
fixSuggestions: []
|
|
692
|
+
};
|
|
693
|
+
try {
|
|
694
|
+
const validator = new ConfigSchemaValidator();
|
|
695
|
+
const configPairs = [{
|
|
696
|
+
glob: 'clodo-create.json',
|
|
697
|
+
type: 'create'
|
|
698
|
+
}, {
|
|
699
|
+
glob: 'clodo-deploy.json',
|
|
700
|
+
type: 'deploy'
|
|
701
|
+
}, {
|
|
702
|
+
glob: 'clodo-validate.json',
|
|
703
|
+
type: 'validate'
|
|
704
|
+
}, {
|
|
705
|
+
glob: 'clodo-update.json',
|
|
706
|
+
type: 'update'
|
|
707
|
+
}];
|
|
708
|
+
let filesChecked = 0;
|
|
709
|
+
let errors = 0;
|
|
710
|
+
for (const {
|
|
711
|
+
glob,
|
|
712
|
+
type
|
|
713
|
+
} of configPairs) {
|
|
714
|
+
// Check in service root and config/ subdirectory
|
|
715
|
+
const candidates = [path.join(servicePath, glob), path.join(servicePath, 'config', glob)];
|
|
716
|
+
for (const filePath of candidates) {
|
|
717
|
+
try {
|
|
718
|
+
await fs.access(filePath);
|
|
719
|
+
} catch {
|
|
720
|
+
continue; // File doesn't exist — skip
|
|
721
|
+
}
|
|
722
|
+
filesChecked++;
|
|
723
|
+
const result = validator.validateConfigFile(filePath, type);
|
|
724
|
+
if (!result.valid) {
|
|
725
|
+
errors++;
|
|
726
|
+
check.details.push(`✗ ${path.relative(servicePath, filePath)}: ${result.errors.length} error(s)`);
|
|
727
|
+
for (const err of result.errors) {
|
|
728
|
+
check.details.push(` ${err.field}: ${err.message}`);
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
check.details.push(`✓ ${path.relative(servicePath, filePath)}: valid (${result.fieldCount} fields)`);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Report warnings
|
|
735
|
+
for (const warn of result.warnings) {
|
|
736
|
+
check.details.push(` ⚠ ${warn.field}: ${warn.message}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (filesChecked === 0) {
|
|
741
|
+
check.details.push('No config files found to validate');
|
|
742
|
+
check.details.push('Config files follow the pattern: clodo-{create|deploy|validate|update}.json');
|
|
743
|
+
} else if (errors > 0) {
|
|
744
|
+
check.status = 'failed';
|
|
745
|
+
check.severity = 'warning';
|
|
746
|
+
check.message = `${errors} config file(s) have schema validation errors`;
|
|
747
|
+
check.fixSuggestions.push('Review the config errors above and fix invalid fields');
|
|
748
|
+
check.fixSuggestions.push('Run: clodo config-schema validate <file> for details');
|
|
749
|
+
check.fixSuggestions.push('Run: clodo config-schema show <type> for schema reference');
|
|
750
|
+
} else {
|
|
751
|
+
check.message = `${filesChecked} config file(s) validated successfully`;
|
|
752
|
+
}
|
|
753
|
+
} catch (error) {
|
|
754
|
+
check.status = 'warning';
|
|
755
|
+
check.severity = 'warning';
|
|
756
|
+
check.details.push(`Config schema validation failed: ${error.message}`);
|
|
757
|
+
}
|
|
758
|
+
return check;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Apply automatic fixes for detected issues
|
|
763
|
+
*/
|
|
764
|
+
async applyFixes(results, servicePath) {
|
|
765
|
+
const fixesApplied = [];
|
|
766
|
+
for (const check of results.checks) {
|
|
767
|
+
if (check.status === 'failed' && check.fixSuggestions) {
|
|
768
|
+
for (const suggestion of check.fixSuggestions) {
|
|
769
|
+
try {
|
|
770
|
+
const fixResult = await this.applySpecificFix(check.name, suggestion, servicePath);
|
|
771
|
+
if (fixResult) {
|
|
772
|
+
fixesApplied.push(`${check.name}: ${fixResult}`);
|
|
773
|
+
console.log(` ✅ ${fixResult}`);
|
|
774
|
+
}
|
|
775
|
+
} catch (error) {
|
|
776
|
+
console.log(` ❌ Failed to apply fix: ${error.message}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return fixesApplied;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Apply a specific fix based on check name and suggestion
|
|
786
|
+
*/
|
|
787
|
+
async applySpecificFix(checkName, suggestion, servicePath) {
|
|
788
|
+
switch (checkName) {
|
|
789
|
+
case 'environment':
|
|
790
|
+
if (suggestion.includes('wrangler')) {
|
|
791
|
+
return await this.fixInstallWrangler();
|
|
792
|
+
}
|
|
793
|
+
break;
|
|
794
|
+
case 'service-validation':
|
|
795
|
+
if (suggestion.includes('dependency')) {
|
|
796
|
+
return await this.fixMissingDependency(suggestion, servicePath);
|
|
797
|
+
}
|
|
798
|
+
if (suggestion.includes('Add main field')) {
|
|
799
|
+
return await this.fixAddMainField(servicePath);
|
|
800
|
+
}
|
|
801
|
+
if (suggestion.includes('Set package.json type to module')) {
|
|
802
|
+
return await this.fixSetModuleType(servicePath);
|
|
803
|
+
}
|
|
804
|
+
if (suggestion.includes('domain')) {
|
|
805
|
+
return await this.fixDomainConfiguration(servicePath);
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
case 'config-presence':
|
|
809
|
+
if (suggestion.includes('Create')) {
|
|
810
|
+
const fileName = suggestion.match(/Create (\S+)/)?.[1];
|
|
811
|
+
if (fileName) {
|
|
812
|
+
return await this.fixCreateConfigFile(fileName, servicePath);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
break;
|
|
816
|
+
}
|
|
817
|
+
return null;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Fix: Install wrangler CLI
|
|
822
|
+
*/
|
|
823
|
+
async fixInstallWrangler() {
|
|
824
|
+
try {
|
|
825
|
+
const {
|
|
826
|
+
execSync
|
|
827
|
+
} = await import('child_process');
|
|
828
|
+
console.log(' 📦 Installing wrangler CLI globally...');
|
|
829
|
+
execSync('npm install -g wrangler', {
|
|
830
|
+
stdio: 'inherit'
|
|
831
|
+
});
|
|
832
|
+
return 'Installed wrangler CLI globally';
|
|
833
|
+
} catch (error) {
|
|
834
|
+
throw new Error(`Failed to install wrangler: ${error.message}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Fix: Add missing dependency to package.json
|
|
840
|
+
*/
|
|
841
|
+
async fixMissingDependency(suggestion, servicePath) {
|
|
842
|
+
const depMatch = suggestion.match(/Missing required dependency: (\S+)/);
|
|
843
|
+
if (!depMatch) return null;
|
|
844
|
+
const dependency = depMatch[1];
|
|
845
|
+
const packageJsonPath = path.join(servicePath, 'package.json');
|
|
846
|
+
try {
|
|
847
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
|
848
|
+
if (!packageJson.dependencies) {
|
|
849
|
+
packageJson.dependencies = {};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Add the framework dependency with latest version
|
|
853
|
+
packageJson.dependencies[dependency] = '^4.5.0';
|
|
854
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
855
|
+
return `Added ${dependency} to package.json dependencies`;
|
|
856
|
+
} catch (error) {
|
|
857
|
+
throw new Error(`Failed to add dependency: ${error.message}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Fix: Add main field to package.json
|
|
863
|
+
*/
|
|
864
|
+
async fixAddMainField(servicePath) {
|
|
865
|
+
const packageJsonPath = path.join(servicePath, 'package.json');
|
|
866
|
+
try {
|
|
867
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
|
868
|
+
packageJson.main = 'src/worker/index.js';
|
|
869
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
870
|
+
return 'Added main field to package.json';
|
|
871
|
+
} catch (error) {
|
|
872
|
+
throw new Error(`Failed to add main field: ${error.message}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Fix: Set package.json type to module
|
|
878
|
+
*/
|
|
879
|
+
async fixSetModuleType(servicePath) {
|
|
880
|
+
const packageJsonPath = path.join(servicePath, 'package.json');
|
|
881
|
+
try {
|
|
882
|
+
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
|
883
|
+
packageJson.type = 'module';
|
|
884
|
+
await fs.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
|
885
|
+
return 'Set package.json type to module';
|
|
886
|
+
} catch (error) {
|
|
887
|
+
throw new Error(`Failed to set module type: ${error.message}`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Fix: Create basic domain configuration
|
|
893
|
+
*/
|
|
894
|
+
async fixDomainConfiguration(servicePath) {
|
|
895
|
+
const domainConfigPath = path.join(servicePath, 'src/config/domains.js');
|
|
896
|
+
const dirPath = path.dirname(domainConfigPath);
|
|
897
|
+
try {
|
|
898
|
+
// Ensure directory exists
|
|
899
|
+
await fs.mkdir(dirPath, {
|
|
900
|
+
recursive: true
|
|
901
|
+
});
|
|
902
|
+
const basicDomainConfig = `/**
|
|
903
|
+
* Domain Configuration
|
|
904
|
+
*
|
|
905
|
+
* Configure domains and routing for your Clodo service
|
|
906
|
+
*/
|
|
907
|
+
|
|
908
|
+
import { createDomainConfigSchema } from '@tamyla/clodo-framework';
|
|
909
|
+
|
|
910
|
+
export const domains = createDomainConfigSchema({
|
|
911
|
+
// Default domain configuration
|
|
912
|
+
default: {
|
|
913
|
+
name: 'example.com',
|
|
914
|
+
routes: [
|
|
915
|
+
{
|
|
916
|
+
pattern: '/',
|
|
917
|
+
handler: 'index'
|
|
918
|
+
}
|
|
919
|
+
]
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
`;
|
|
923
|
+
await fs.writeFile(domainConfigPath, basicDomainConfig);
|
|
924
|
+
return 'Created basic domain configuration file';
|
|
925
|
+
} catch (error) {
|
|
926
|
+
throw new Error(`Failed to create domain config: ${error.message}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Fix: Create basic configuration file
|
|
932
|
+
*/
|
|
933
|
+
async fixCreateConfigFile(fileName, servicePath) {
|
|
934
|
+
const filePath = path.join(servicePath, fileName);
|
|
935
|
+
const dirPath = path.dirname(filePath);
|
|
936
|
+
try {
|
|
937
|
+
// Ensure directory exists
|
|
938
|
+
await fs.mkdir(dirPath, {
|
|
939
|
+
recursive: true
|
|
940
|
+
});
|
|
941
|
+
let content = '';
|
|
942
|
+
switch (fileName) {
|
|
943
|
+
case 'wrangler.toml':
|
|
944
|
+
content = `name = "my-clodo-service"
|
|
945
|
+
main = "src/worker/index.js"
|
|
946
|
+
compatibility_date = "${new Date().toISOString().split('T')[0]}"
|
|
947
|
+
|
|
948
|
+
[vars]
|
|
949
|
+
NODE_ENV = "production"
|
|
950
|
+
`;
|
|
951
|
+
break;
|
|
952
|
+
case 'src/worker/index.js':
|
|
953
|
+
content = `/**
|
|
954
|
+
* Main Worker Entry Point
|
|
955
|
+
*/
|
|
956
|
+
|
|
957
|
+
import { createServiceRouter } from '@tamyla/clodo-framework';
|
|
958
|
+
|
|
959
|
+
export default {
|
|
960
|
+
async fetch(request, env, ctx) {
|
|
961
|
+
const router = createServiceRouter({
|
|
962
|
+
// Configure your service routes here
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
return router.handle(request, env, ctx);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
`;
|
|
969
|
+
break;
|
|
970
|
+
case 'src/config/domains.js':
|
|
971
|
+
return await this.fixDomainConfiguration(servicePath);
|
|
972
|
+
default:
|
|
973
|
+
content = `# ${fileName}
|
|
974
|
+
# Basic configuration file created by clodo doctor --fix
|
|
975
|
+
`;
|
|
976
|
+
}
|
|
977
|
+
await fs.writeFile(filePath, content);
|
|
978
|
+
return `Created ${fileName} with basic configuration`;
|
|
979
|
+
} catch (error) {
|
|
980
|
+
throw new Error(`Failed to create ${fileName}: ${error.message}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
287
983
|
}
|