@odavl/guardian 0.1.0-rc1 → 1.0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1544 -55
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +26 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +587 -12
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +85 -39
  21. package/src/guardian/attempts-filter.js +63 -0
  22. package/src/guardian/baseline.js +50 -8
  23. package/src/guardian/breakage-intelligence.js +1 -0
  24. package/src/guardian/browser-pool.js +131 -0
  25. package/src/guardian/browser.js +28 -1
  26. package/src/guardian/ci-cli.js +121 -0
  27. package/src/guardian/ci-mode.js +15 -0
  28. package/src/guardian/ci-output.js +38 -0
  29. package/src/guardian/cli-summary.js +167 -67
  30. package/src/guardian/config-loader.js +162 -0
  31. package/src/guardian/data-guardian-detector.js +189 -0
  32. package/src/guardian/detection-layers.js +271 -0
  33. package/src/guardian/drift-detector.js +100 -0
  34. package/src/guardian/enhanced-html-reporter.js +221 -4
  35. package/src/guardian/env-guard.js +127 -0
  36. package/src/guardian/failure-intelligence.js +173 -0
  37. package/src/guardian/first-run-profile.js +89 -0
  38. package/src/guardian/first-run.js +54 -0
  39. package/src/guardian/flag-validator.js +111 -0
  40. package/src/guardian/flow-executor.js +309 -44
  41. package/src/guardian/html-reporter.js +2 -0
  42. package/src/guardian/human-reporter.js +431 -0
  43. package/src/guardian/index.js +22 -19
  44. package/src/guardian/init-command.js +9 -5
  45. package/src/guardian/intent-detector.js +146 -0
  46. package/src/guardian/journey-definitions.js +132 -0
  47. package/src/guardian/journey-scan-cli.js +145 -0
  48. package/src/guardian/journey-scanner.js +583 -0
  49. package/src/guardian/junit-reporter.js +18 -1
  50. package/src/guardian/language-detection.js +99 -0
  51. package/src/guardian/live-cli.js +95 -0
  52. package/src/guardian/live-scheduler-runner.js +137 -0
  53. package/src/guardian/live-scheduler.js +146 -0
  54. package/src/guardian/market-reporter.js +357 -82
  55. package/src/guardian/parallel-executor.js +116 -0
  56. package/src/guardian/pattern-analyzer.js +348 -0
  57. package/src/guardian/policy.js +80 -3
  58. package/src/guardian/prerequisite-checker.js +101 -0
  59. package/src/guardian/preset-loader.js +27 -18
  60. package/src/guardian/profile-loader.js +96 -0
  61. package/src/guardian/reality.js +1612 -115
  62. package/src/guardian/reporter.js +27 -41
  63. package/src/guardian/run-artifacts.js +212 -0
  64. package/src/guardian/run-cleanup.js +207 -0
  65. package/src/guardian/run-latest.js +90 -0
  66. package/src/guardian/run-list.js +211 -0
  67. package/src/guardian/run-summary.js +20 -0
  68. package/src/guardian/scan-presets.js +100 -11
  69. package/src/guardian/selector-fallbacks.js +394 -0
  70. package/src/guardian/semantic-contact-detection.js +255 -0
  71. package/src/guardian/semantic-contact-finder.js +201 -0
  72. package/src/guardian/semantic-targets.js +234 -0
  73. package/src/guardian/site-introspection.js +257 -0
  74. package/src/guardian/smoke.js +258 -0
  75. package/src/guardian/snapshot-schema.js +25 -1
  76. package/src/guardian/snapshot.js +69 -3
  77. package/src/guardian/stability-scorer.js +169 -0
  78. package/src/guardian/success-evaluator.js +214 -0
  79. package/src/guardian/template-command.js +184 -0
  80. package/src/guardian/text-formatters.js +426 -0
  81. package/src/guardian/timeout-profiles.js +57 -0
  82. package/src/guardian/verdict.js +320 -0
  83. package/src/guardian/verdicts.js +74 -0
  84. package/src/guardian/wait-for-outcome.js +120 -0
  85. package/src/guardian/watch-runner.js +181 -0
  86. package/src/payments/stripe-checkout.js +169 -0
  87. package/src/plans/plan-definitions.js +148 -0
  88. package/src/plans/plan-manager.js +211 -0
  89. package/src/plans/usage-tracker.js +210 -0
  90. package/src/recipes/recipe-engine.js +188 -0
  91. package/src/recipes/recipe-failure-analysis.js +159 -0
  92. package/src/recipes/recipe-registry.js +134 -0
  93. package/src/recipes/recipe-runtime.js +507 -0
  94. package/src/recipes/recipe-store.js +410 -0
  95. package/guardian-contract-v1.md +0 -149
  96. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  97. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  98. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  99. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  100. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  101. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * ODAVL Guardian Usage Tracker
3
+ * Tracks scans per month and enforces plan limits
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ const USAGE_DIR = path.join(os.homedir(), '.odavl-guardian', 'usage');
11
+ const USAGE_FILE = path.join(USAGE_DIR, 'usage.json');
12
+
13
+ /**
14
+ * Ensure usage directory exists
15
+ */
16
+ function ensureUsageDir() {
17
+ if (!fs.existsSync(USAGE_DIR)) {
18
+ fs.mkdirSync(USAGE_DIR, { recursive: true });
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Get current month key (YYYY-MM)
24
+ */
25
+ function getCurrentMonthKey() {
26
+ const now = new Date();
27
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
28
+ }
29
+
30
+ /**
31
+ * Load usage data
32
+ */
33
+ function loadUsage() {
34
+ ensureUsageDir();
35
+
36
+ if (!fs.existsSync(USAGE_FILE)) {
37
+ return {
38
+ currentMonth: getCurrentMonthKey(),
39
+ scansThisMonth: 0,
40
+ sites: [],
41
+ totalScans: 0,
42
+ history: {},
43
+ };
44
+ }
45
+
46
+ try {
47
+ const data = JSON.parse(fs.readFileSync(USAGE_FILE, 'utf-8'));
48
+
49
+ // Reset if new month
50
+ const currentMonth = getCurrentMonthKey();
51
+ if (data.currentMonth !== currentMonth) {
52
+ // Archive old month
53
+ data.history[data.currentMonth] = {
54
+ scans: data.scansThisMonth,
55
+ sites: data.sites.length,
56
+ };
57
+
58
+ // Reset for new month
59
+ data.currentMonth = currentMonth;
60
+ data.scansThisMonth = 0;
61
+ }
62
+
63
+ return data;
64
+ } catch (error) {
65
+ console.error('Error loading usage data:', error.message);
66
+ return {
67
+ currentMonth: getCurrentMonthKey(),
68
+ scansThisMonth: 0,
69
+ sites: [],
70
+ totalScans: 0,
71
+ history: {},
72
+ };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Save usage data
78
+ */
79
+ function saveUsage(usage) {
80
+ ensureUsageDir();
81
+ fs.writeFileSync(USAGE_FILE, JSON.stringify(usage, null, 2), 'utf-8');
82
+ }
83
+
84
+ /**
85
+ * Record a scan
86
+ */
87
+ function recordScan(url) {
88
+ const usage = loadUsage();
89
+
90
+ // Extract domain from URL
91
+ let domain;
92
+ try {
93
+ const urlObj = new URL(url);
94
+ domain = urlObj.hostname;
95
+ } catch {
96
+ domain = url;
97
+ }
98
+
99
+ // Increment scan count
100
+ usage.scansThisMonth += 1;
101
+ usage.totalScans += 1;
102
+
103
+ // Track unique site
104
+ if (!usage.sites.includes(domain)) {
105
+ usage.sites.push(domain);
106
+ }
107
+
108
+ // Record timestamp
109
+ if (!usage.scanHistory) {
110
+ usage.scanHistory = [];
111
+ }
112
+ usage.scanHistory.push({
113
+ url,
114
+ domain,
115
+ timestamp: new Date().toISOString(),
116
+ });
117
+
118
+ // Keep only last 100 scans in history
119
+ if (usage.scanHistory.length > 100) {
120
+ usage.scanHistory = usage.scanHistory.slice(-100);
121
+ }
122
+
123
+ saveUsage(usage);
124
+ return usage;
125
+ }
126
+
127
+ /**
128
+ * Get current usage stats
129
+ */
130
+ function getUsageStats() {
131
+ return loadUsage();
132
+ }
133
+
134
+ /**
135
+ * Get scans remaining for current plan
136
+ */
137
+ function getScansRemaining(maxScans) {
138
+ const usage = loadUsage();
139
+ if (maxScans === -1) return 'Unlimited';
140
+ return Math.max(0, maxScans - usage.scansThisMonth);
141
+ }
142
+
143
+ /**
144
+ * Check if can perform scan
145
+ */
146
+ function canPerformScan(maxScans) {
147
+ if (maxScans === -1) return true; // unlimited
148
+ const usage = loadUsage();
149
+ return usage.scansThisMonth < maxScans;
150
+ }
151
+
152
+ /**
153
+ * Check if can add site
154
+ */
155
+ function canAddSite(url, maxSites) {
156
+ if (maxSites === -1) return true; // unlimited
157
+
158
+ const usage = loadUsage();
159
+
160
+ // Extract domain
161
+ let domain;
162
+ try {
163
+ const urlObj = new URL(url);
164
+ domain = urlObj.hostname;
165
+ } catch {
166
+ domain = url;
167
+ }
168
+
169
+ // If site already tracked, it's OK
170
+ if (usage.sites.includes(domain)) {
171
+ return true;
172
+ }
173
+
174
+ // Check if under limit
175
+ return usage.sites.length < maxSites;
176
+ }
177
+
178
+ /**
179
+ * Reset usage (for testing or manual reset)
180
+ */
181
+ function resetUsage() {
182
+ ensureUsageDir();
183
+ const usage = {
184
+ currentMonth: getCurrentMonthKey(),
185
+ scansThisMonth: 0,
186
+ sites: [],
187
+ totalScans: 0,
188
+ history: {},
189
+ scanHistory: [],
190
+ };
191
+ saveUsage(usage);
192
+ return usage;
193
+ }
194
+
195
+ /**
196
+ * Get usage file path (for debugging)
197
+ */
198
+ function getUsageFilePath() {
199
+ return USAGE_FILE;
200
+ }
201
+
202
+ module.exports = {
203
+ recordScan,
204
+ getUsageStats,
205
+ getScansRemaining,
206
+ canPerformScan,
207
+ canAddSite,
208
+ resetUsage,
209
+ getUsageFilePath,
210
+ };
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Phase 12.1: Recipe Engine
3
+ * Core recipe execution and validation system
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Validate recipe schema
11
+ */
12
+ function validateRecipe(recipe) {
13
+ const errors = [];
14
+
15
+ if (!recipe.id || typeof recipe.id !== 'string') {
16
+ errors.push('Recipe must have a string id');
17
+ }
18
+
19
+ if (!recipe.name || typeof recipe.name !== 'string') {
20
+ errors.push('Recipe must have a string name');
21
+ }
22
+
23
+ if (!recipe.platform || typeof recipe.platform !== 'string') {
24
+ errors.push('Recipe must have a platform (shopify|saas|landing)');
25
+ }
26
+
27
+ if (!['shopify', 'saas', 'landing'].includes(recipe.platform)) {
28
+ errors.push(`Invalid platform: ${recipe.platform}. Must be shopify, saas, or landing`);
29
+ }
30
+
31
+ if (!recipe.intent || typeof recipe.intent !== 'string') {
32
+ errors.push('Recipe must have an intent string');
33
+ }
34
+
35
+ if (!Array.isArray(recipe.steps) || recipe.steps.length === 0) {
36
+ errors.push('Recipe must have at least one step');
37
+ }
38
+
39
+ if (recipe.steps && !recipe.steps.every(s => typeof s === 'string')) {
40
+ errors.push('All recipe steps must be strings');
41
+ }
42
+
43
+ if (!recipe.expectedGoal || typeof recipe.expectedGoal !== 'string') {
44
+ errors.push('Recipe must have an expectedGoal');
45
+ }
46
+
47
+ return {
48
+ valid: errors.length === 0,
49
+ errors
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Get recipe complexity score
55
+ */
56
+ function getComplexityScore(recipe) {
57
+ const stepCount = recipe.steps.length;
58
+
59
+ if (stepCount <= 3) return 'simple';
60
+ if (stepCount <= 7) return 'moderate';
61
+ return 'complex';
62
+ }
63
+
64
+ /**
65
+ * Get estimated execution time (seconds)
66
+ */
67
+ function getEstimatedTime(recipe) {
68
+ const stepCount = recipe.steps.length;
69
+ const complexity = getComplexityScore(recipe);
70
+
71
+ // Base time per step
72
+ let baseTime = 5;
73
+
74
+ if (complexity === 'complex') {
75
+ baseTime = 8;
76
+ } else if (complexity === 'moderate') {
77
+ baseTime = 6;
78
+ }
79
+
80
+ return stepCount * baseTime;
81
+ }
82
+
83
+ /**
84
+ * Create recipe execution context
85
+ */
86
+ function createExecutionContext(recipe, baseUrl, options = {}) {
87
+ return {
88
+ recipe,
89
+ baseUrl,
90
+ startedAt: new Date(),
91
+ steps: [],
92
+ state: {
93
+ currentUrl: baseUrl,
94
+ pageTitle: null,
95
+ pageUrl: null,
96
+ lastElement: null,
97
+ data: options.data || {}
98
+ },
99
+ options,
100
+ success: false,
101
+ error: null,
102
+ completedAt: null,
103
+ duration: 0
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Record step execution
109
+ */
110
+ function recordStep(context, stepIndex, action, result) {
111
+ const step = {
112
+ index: stepIndex,
113
+ action,
114
+ result,
115
+ timestamp: new Date().toISOString(),
116
+ duration: 0
117
+ };
118
+
119
+ context.steps.push(step);
120
+ return step;
121
+ }
122
+
123
+ /**
124
+ * Mark execution complete
125
+ */
126
+ function markComplete(context, success, error = null) {
127
+ context.success = success;
128
+ context.error = error;
129
+ context.completedAt = new Date();
130
+ context.duration = Math.round(
131
+ (context.completedAt - context.startedAt) / 1000
132
+ );
133
+ return context;
134
+ }
135
+
136
+ /**
137
+ * Generate human-readable recipe summary
138
+ */
139
+ function getRecipeSummary(recipe) {
140
+ return {
141
+ id: recipe.id,
142
+ name: recipe.name,
143
+ platform: recipe.platform,
144
+ intent: recipe.intent,
145
+ stepCount: recipe.steps.length,
146
+ complexity: getComplexityScore(recipe),
147
+ estimatedTime: getEstimatedTime(recipe),
148
+ expectedGoal: recipe.expectedGoal
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Format recipe for display
154
+ */
155
+ function formatRecipe(recipe) {
156
+ const summary = getRecipeSummary(recipe);
157
+
158
+ let output = '';
159
+ output += `📋 ${summary.name}\n`;
160
+ output += ` Platform: ${summary.platform}\n`;
161
+ output += ` Intent: ${summary.intent}\n`;
162
+ output += ` Complexity: ${summary.complexity}\n`;
163
+ output += ` Steps: ${summary.stepCount}\n`;
164
+ output += ` Estimated Time: ${summary.estimatedTime}s\n`;
165
+ output += ` Expected Goal: ${summary.expectedGoal}\n`;
166
+
167
+ if (recipe.notes) {
168
+ output += ` Notes: ${recipe.notes}\n`;
169
+ }
170
+
171
+ output += `\n Steps:\n`;
172
+ recipe.steps.forEach((step, i) => {
173
+ output += ` ${i + 1}. ${step}\n`;
174
+ });
175
+
176
+ return output;
177
+ }
178
+
179
+ module.exports = {
180
+ validateRecipe,
181
+ getComplexityScore,
182
+ getEstimatedTime,
183
+ createExecutionContext,
184
+ recordStep,
185
+ markComplete,
186
+ getRecipeSummary,
187
+ formatRecipe
188
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Recipe Failure Analysis & Reporting
3
+ *
4
+ * Integrates recipe failures into the decision engine and breakage intelligence.
5
+ */
6
+
7
+ /**
8
+ * Analyze a recipe execution failure
9
+ *
10
+ * @param {Object} recipeResult - Result from executeRecipeRuntime
11
+ * @returns {Object} Intelligence object for reporting
12
+ */
13
+ function analyzeRecipeFailure(recipeResult) {
14
+ if (recipeResult.success) {
15
+ return null; // No failure to analyze
16
+ }
17
+
18
+ const { failedStep, failureReason, steps, recipe, recipeName, baseUrl } = recipeResult;
19
+
20
+ // Categorize failure type
21
+ let failureType = 'EXECUTION';
22
+ let severity = 'CRITICAL';
23
+
24
+ if (failureReason.includes('not found')) {
25
+ failureType = 'ELEMENT_NOT_FOUND';
26
+ } else if (failureReason.includes('not visible')) {
27
+ failureType = 'ELEMENT_NOT_VISIBLE';
28
+ } else if (failureReason.includes('Goal not reached')) {
29
+ failureType = 'GOAL_FAILURE';
30
+ } else if (failureReason.includes('Runtime error')) {
31
+ failureType = 'RUNTIME_ERROR';
32
+ severity = 'CRITICAL';
33
+ } else if (failureReason.includes('Recipe not found')) {
34
+ failureType = 'RECIPE_NOT_FOUND';
35
+ severity = 'INFO';
36
+ }
37
+
38
+ const stepsFailed = steps.filter(s => !s.success).length;
39
+ const stepsTotal = steps.length;
40
+
41
+ return {
42
+ id: `recipe-${recipe}`,
43
+ name: `Recipe: ${recipeName}`,
44
+ outcome: 'FAILURE',
45
+ failureType,
46
+ severity,
47
+ baseUrl,
48
+ recipeId: recipe,
49
+ recipeName,
50
+ failedStepId: failedStep,
51
+ failedStepNumber: failedStep ? parseInt(failedStep.split('-').pop(), 10) : null,
52
+ failureReason,
53
+ stepProgress: {
54
+ completed: stepsTotal - stepsFailed,
55
+ total: stepsTotal,
56
+ percentage: Math.round(((stepsTotal - stepsFailed) / stepsTotal) * 100)
57
+ },
58
+ humanReadableError: formatRecipeError(recipeName, failedStep, failureReason, stepsTotal),
59
+ source: 'recipe-runtime',
60
+ behavioralSignals: [
61
+ {
62
+ signal: 'RECIPE_EXECUTION_FAILED',
63
+ message: failureReason,
64
+ severity: severity
65
+ }
66
+ ]
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Format recipe error for human readability
72
+ */
73
+ function formatRecipeError(recipeName, failedStep, failureReason, totalSteps) {
74
+ const stepNum = failedStep ? parseInt(failedStep.split('-').pop(), 10) + 1 : 0;
75
+
76
+ return `Recipe '${recipeName}' failed at step ${stepNum} of ${totalSteps}: ${failureReason}`;
77
+ }
78
+
79
+ /**
80
+ * Convert recipe failure to attempt-like structure for reporting compatibility
81
+ */
82
+ function recipeFailureToAttempt(recipeResult) {
83
+ const intelligence = analyzeRecipeFailure(recipeResult);
84
+ if (!intelligence) return null;
85
+
86
+ return {
87
+ attemptId: intelligence.id,
88
+ attemptName: intelligence.name,
89
+ outcome: 'FAILURE',
90
+ error: intelligence.failureReason,
91
+ failureType: intelligence.failureType,
92
+ severity: intelligence.severity,
93
+ baseUrl: intelligence.baseUrl,
94
+ riskCategory: 'TRUST', // Recipes are trust/integrity checks
95
+ source: 'recipe',
96
+ validators: [
97
+ {
98
+ id: 'recipe-step-validation',
99
+ name: 'Recipe Step Execution',
100
+ passed: false,
101
+ evidence: recipeResult.evidence
102
+ }
103
+ ],
104
+ behavioralSignals: intelligence.behavioralSignals
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Integrate recipe failures into market impact analysis
110
+ */
111
+ function assessRecipeImpact(recipeResult) {
112
+ if (recipeResult.success) {
113
+ return {
114
+ hasRisk: false,
115
+ riskScore: 0,
116
+ severity: 'INFO',
117
+ message: `Recipe '${recipeResult.recipeName}' passed successfully`
118
+ };
119
+ }
120
+
121
+ const intelligence = analyzeRecipeFailure(recipeResult);
122
+
123
+ // Recipe failures always elevate risk
124
+ let riskScore = 70; // Base critical risk
125
+ let severity = 'CRITICAL';
126
+
127
+ // Adjust based on step completion
128
+ const progress = intelligence.stepProgress.percentage;
129
+ if (progress === 0) {
130
+ riskScore = 90; // Failed on first step — very bad
131
+ } else if (progress < 50) {
132
+ riskScore = 80; // Failed early
133
+ } else {
134
+ riskScore = 60; // Failed late but still indicates issue
135
+ }
136
+
137
+ // Type-specific scoring
138
+ if (intelligence.failureType === 'GOAL_FAILURE') {
139
+ riskScore = Math.max(riskScore - 10, 50); // Goal failure less severe than execution error
140
+ severity = 'WARNING';
141
+ }
142
+
143
+ return {
144
+ hasRisk: true,
145
+ riskScore,
146
+ severity,
147
+ message: intelligence.humanReadableError,
148
+ recipe: intelligence.recipeId,
149
+ failureType: intelligence.failureType,
150
+ stepProgress: intelligence.stepProgress
151
+ };
152
+ }
153
+
154
+ module.exports = {
155
+ analyzeRecipeFailure,
156
+ formatRecipeError,
157
+ recipeFailureToAttempt,
158
+ assessRecipeImpact
159
+ };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Phase 12.2: Recipe Registry
3
+ * Local-first registry tracking provenance and integrity of recipes.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const crypto = require('crypto');
10
+
11
+ const RECIPES_DIR = path.join(os.homedir(), '.odavl-guardian', 'recipes');
12
+ const REGISTRY_FILE = path.join(RECIPES_DIR, 'registry.json');
13
+
14
+ function ensureRecipesDir() {
15
+ if (!fs.existsSync(RECIPES_DIR)) {
16
+ fs.mkdirSync(RECIPES_DIR, { recursive: true });
17
+ }
18
+ }
19
+
20
+ function stableStringify(value) {
21
+ if (value === null || typeof value !== 'object') {
22
+ return JSON.stringify(value);
23
+ }
24
+ if (Array.isArray(value)) {
25
+ return '[' + value.map(v => stableStringify(v)).join(',') + ']';
26
+ }
27
+ const keys = Object.keys(value).sort();
28
+ const entries = keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
29
+ return '{' + entries.join(',') + '}';
30
+ }
31
+
32
+ function computeRecipeChecksum(recipe) {
33
+ const normalized = stableStringify(recipe);
34
+ return crypto.createHash('sha256').update(normalized).digest('hex');
35
+ }
36
+
37
+ function loadRegistry() {
38
+ ensureRecipesDir();
39
+ if (!fs.existsSync(REGISTRY_FILE)) {
40
+ return { entries: [], updatedAt: null };
41
+ }
42
+ try {
43
+ return JSON.parse(fs.readFileSync(REGISTRY_FILE, 'utf-8'));
44
+ } catch (err) {
45
+ return { entries: [], updatedAt: null };
46
+ }
47
+ }
48
+
49
+ function saveRegistry(registry) {
50
+ ensureRecipesDir();
51
+ const data = {
52
+ entries: registry.entries || [],
53
+ updatedAt: new Date().toISOString(),
54
+ };
55
+ fs.writeFileSync(REGISTRY_FILE, JSON.stringify(data, null, 2), 'utf-8');
56
+ return data;
57
+ }
58
+
59
+ function ensureBuiltInRegistry(builtIns) {
60
+ const registry = loadRegistry();
61
+ let changed = false;
62
+ for (const recipe of builtIns) {
63
+ if (!registry.entries.some(e => e.id === recipe.id)) {
64
+ registry.entries.push({
65
+ id: recipe.id,
66
+ name: recipe.name,
67
+ platform: recipe.platform,
68
+ version: recipe.version || '1.0.0',
69
+ source: 'builtin',
70
+ checksum: computeRecipeChecksum(recipe),
71
+ addedAt: new Date().toISOString(),
72
+ });
73
+ changed = true;
74
+ }
75
+ }
76
+ if (changed) {
77
+ saveRegistry(registry);
78
+ }
79
+ }
80
+
81
+ function registerRecipe({ id, name, platform, version, source, checksum }) {
82
+ const registry = loadRegistry();
83
+ const existing = registry.entries.find(e => e.id === id);
84
+ if (existing) {
85
+ Object.assign(existing, { name, platform, version, source, checksum });
86
+ } else {
87
+ registry.entries.push({
88
+ id,
89
+ name,
90
+ platform,
91
+ version: version || '1.0.0',
92
+ source: source || 'imported',
93
+ checksum,
94
+ addedAt: new Date().toISOString(),
95
+ });
96
+ }
97
+ return saveRegistry(registry);
98
+ }
99
+
100
+ function removeRegistryEntry(id) {
101
+ const registry = loadRegistry();
102
+ const next = registry.entries.filter(e => e.id !== id);
103
+ registry.entries = next;
104
+ return saveRegistry(registry);
105
+ }
106
+
107
+ function getRegistryEntry(id) {
108
+ const registry = loadRegistry();
109
+ return registry.entries.find(e => e.id === id);
110
+ }
111
+
112
+ function listRegistryEntries() {
113
+ const registry = loadRegistry();
114
+ return registry.entries;
115
+ }
116
+
117
+ function resetRegistry() {
118
+ ensureRecipesDir();
119
+ if (fs.existsSync(REGISTRY_FILE)) {
120
+ fs.unlinkSync(REGISTRY_FILE);
121
+ }
122
+ }
123
+
124
+ module.exports = {
125
+ computeRecipeChecksum,
126
+ loadRegistry,
127
+ saveRegistry,
128
+ registerRecipe,
129
+ ensureBuiltInRegistry,
130
+ getRegistryEntry,
131
+ listRegistryEntries,
132
+ removeRegistryEntry,
133
+ resetRegistry,
134
+ };