@odavl/guardian 0.2.0 → 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.
- package/CHANGELOG.md +86 -2
- package/README.md +155 -97
- package/bin/guardian.js +1345 -60
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +21 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +568 -7
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +24 -0
- package/src/guardian/baseline.js +12 -4
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-output.js +4 -3
- package/src/guardian/cli-summary.js +79 -92
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +6 -1
- package/src/guardian/flag-validator.js +17 -3
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +341 -81
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/preset-loader.js +9 -6
- package/src/guardian/reality.js +1278 -117
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-finder.js +2 -1
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +2 -2
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +46 -2
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/watch-runner.js +3 -7
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase C: RBAC Gate Enforcement
|
|
3
|
+
* Single shared gate for all sensitive commands
|
|
4
|
+
* Ensures no bypass paths exist
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { requirePermission } = require('./rbac');
|
|
8
|
+
const { logAudit, AUDIT_ACTIONS } = require('./audit-logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Definitive list of all sensitive commands and their required permissions
|
|
12
|
+
*/
|
|
13
|
+
const SENSITIVE_COMMANDS = {
|
|
14
|
+
// Scan operations
|
|
15
|
+
'scan:run': {
|
|
16
|
+
action: AUDIT_ACTIONS.SCAN_RUN,
|
|
17
|
+
description: 'Execute scan',
|
|
18
|
+
},
|
|
19
|
+
'scan:view': {
|
|
20
|
+
action: AUDIT_ACTIONS.SCAN_VIEW,
|
|
21
|
+
description: 'View scan results',
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
// Live scheduling
|
|
25
|
+
'live:start': {
|
|
26
|
+
action: AUDIT_ACTIONS.LIVE_START,
|
|
27
|
+
description: 'Start live scheduler',
|
|
28
|
+
},
|
|
29
|
+
'live:stop': {
|
|
30
|
+
action: AUDIT_ACTIONS.LIVE_STOP,
|
|
31
|
+
description: 'Stop live scheduler',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
// Site management
|
|
35
|
+
'site:add': {
|
|
36
|
+
action: AUDIT_ACTIONS.SITE_ADD,
|
|
37
|
+
description: 'Add site',
|
|
38
|
+
},
|
|
39
|
+
'site:remove': {
|
|
40
|
+
action: AUDIT_ACTIONS.SITE_REMOVE,
|
|
41
|
+
description: 'Remove site',
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// User management
|
|
45
|
+
'user:add': {
|
|
46
|
+
action: AUDIT_ACTIONS.USER_ADD,
|
|
47
|
+
description: 'Add user',
|
|
48
|
+
},
|
|
49
|
+
'user:remove': {
|
|
50
|
+
action: AUDIT_ACTIONS.USER_REMOVE,
|
|
51
|
+
description: 'Remove user',
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
// Recipe management
|
|
55
|
+
'recipe:import': {
|
|
56
|
+
action: AUDIT_ACTIONS.RECIPE_IMPORT,
|
|
57
|
+
description: 'Import recipe',
|
|
58
|
+
},
|
|
59
|
+
'recipe:export': {
|
|
60
|
+
action: AUDIT_ACTIONS.RECIPE_EXPORT,
|
|
61
|
+
description: 'Export recipe',
|
|
62
|
+
},
|
|
63
|
+
'recipe:remove': {
|
|
64
|
+
action: 'recipe:remove',
|
|
65
|
+
description: 'Remove recipe',
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
// Export operations
|
|
69
|
+
'export:pdf': {
|
|
70
|
+
action: AUDIT_ACTIONS.EXPORT_PDF,
|
|
71
|
+
description: 'Export PDF report',
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
// Plan operations
|
|
75
|
+
'plan:upgrade': {
|
|
76
|
+
action: AUDIT_ACTIONS.PLAN_UPGRADE,
|
|
77
|
+
description: 'Upgrade plan',
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// Audit operations
|
|
81
|
+
'audit:view': {
|
|
82
|
+
action: 'audit:view',
|
|
83
|
+
description: 'View audit logs',
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Gate function: Check permission and log action
|
|
89
|
+
* This is the SINGLE entry point for all sensitive commands
|
|
90
|
+
*
|
|
91
|
+
* @param {string} permission - Permission string (e.g., 'scan:run', 'user:add')
|
|
92
|
+
* @param {Object} context - Optional context info for audit log
|
|
93
|
+
* @throws {Error} If permission denied
|
|
94
|
+
*/
|
|
95
|
+
function enforceGate(permission, context = {}) {
|
|
96
|
+
// Validate permission exists in sensitive commands
|
|
97
|
+
if (!SENSITIVE_COMMANDS[permission]) {
|
|
98
|
+
throw new Error(`Unknown sensitive command: ${permission}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Enforce RBAC
|
|
102
|
+
requirePermission(permission, SENSITIVE_COMMANDS[permission].description);
|
|
103
|
+
|
|
104
|
+
// Log to audit trail (immediately after successful permission check)
|
|
105
|
+
const cmdInfo = SENSITIVE_COMMANDS[permission];
|
|
106
|
+
logAudit(cmdInfo.action, {
|
|
107
|
+
permission,
|
|
108
|
+
description: cmdInfo.description,
|
|
109
|
+
...context,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Return context for caller to use
|
|
113
|
+
return {
|
|
114
|
+
permission,
|
|
115
|
+
action: cmdInfo.action,
|
|
116
|
+
description: cmdInfo.description,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get all sensitive commands (for testing/documentation)
|
|
122
|
+
*/
|
|
123
|
+
function listSensitiveCommands() {
|
|
124
|
+
return Object.entries(SENSITIVE_COMMANDS).map(([permission, info]) => ({
|
|
125
|
+
permission,
|
|
126
|
+
...info,
|
|
127
|
+
}));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check if a permission is sensitive (gated)
|
|
132
|
+
*/
|
|
133
|
+
function isSensitiveCommand(permission) {
|
|
134
|
+
return permission in SENSITIVE_COMMANDS;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
enforceGate,
|
|
139
|
+
listSensitiveCommands,
|
|
140
|
+
isSensitiveCommand,
|
|
141
|
+
SENSITIVE_COMMANDS,
|
|
142
|
+
};
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11: Role-Based Access Control (RBAC)
|
|
3
|
+
* Enforce permission checks for enterprise features
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const RBAC_DIR = path.join(os.homedir(), '.odavl-guardian', 'rbac');
|
|
11
|
+
const USERS_FILE = path.join(RBAC_DIR, 'users.json');
|
|
12
|
+
|
|
13
|
+
// Role definitions
|
|
14
|
+
const ROLES = {
|
|
15
|
+
ADMIN: {
|
|
16
|
+
name: 'ADMIN',
|
|
17
|
+
permissions: [
|
|
18
|
+
'scan:run',
|
|
19
|
+
'scan:view',
|
|
20
|
+
'live:run',
|
|
21
|
+
'site:add',
|
|
22
|
+
'site:remove',
|
|
23
|
+
'site:view',
|
|
24
|
+
'plan:view',
|
|
25
|
+
'plan:upgrade',
|
|
26
|
+
'user:add',
|
|
27
|
+
'user:remove',
|
|
28
|
+
'user:view',
|
|
29
|
+
'audit:view',
|
|
30
|
+
'export:pdf',
|
|
31
|
+
'recipe:manage',
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
OPERATOR: {
|
|
35
|
+
name: 'OPERATOR',
|
|
36
|
+
permissions: [
|
|
37
|
+
'scan:run',
|
|
38
|
+
'scan:view',
|
|
39
|
+
'live:run',
|
|
40
|
+
'site:view',
|
|
41
|
+
'plan:view',
|
|
42
|
+
'export:pdf',
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
VIEWER: {
|
|
46
|
+
name: 'VIEWER',
|
|
47
|
+
permissions: [
|
|
48
|
+
'scan:view',
|
|
49
|
+
'site:view',
|
|
50
|
+
'plan:view',
|
|
51
|
+
'audit:view',
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Ensure RBAC directory exists
|
|
58
|
+
*/
|
|
59
|
+
function ensureRbacDir() {
|
|
60
|
+
if (!fs.existsSync(RBAC_DIR)) {
|
|
61
|
+
fs.mkdirSync(RBAC_DIR, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get all users
|
|
67
|
+
*/
|
|
68
|
+
function getUsers() {
|
|
69
|
+
ensureRbacDir();
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(USERS_FILE)) {
|
|
72
|
+
// Default: current user is ADMIN
|
|
73
|
+
const defaultUser = {
|
|
74
|
+
username: os.userInfo().username || 'admin',
|
|
75
|
+
role: 'ADMIN',
|
|
76
|
+
addedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
return { users: [defaultUser] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(USERS_FILE, 'utf-8'));
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const defaultUser = {
|
|
85
|
+
username: os.userInfo().username || 'admin',
|
|
86
|
+
role: 'ADMIN',
|
|
87
|
+
addedAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
return { users: [defaultUser] };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Save users
|
|
95
|
+
*/
|
|
96
|
+
function saveUsers(data) {
|
|
97
|
+
ensureRbacDir();
|
|
98
|
+
fs.writeFileSync(USERS_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Add a user
|
|
103
|
+
*/
|
|
104
|
+
function addUser(username, role = 'VIEWER') {
|
|
105
|
+
if (!ROLES[role]) {
|
|
106
|
+
throw new Error(`Invalid role: ${role}. Must be ADMIN, OPERATOR, or VIEWER`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = getUsers();
|
|
110
|
+
|
|
111
|
+
// Check if user already exists
|
|
112
|
+
const existing = data.users.find(u => u.username === username);
|
|
113
|
+
if (existing) {
|
|
114
|
+
throw new Error(`User '${username}' already exists`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const user = {
|
|
118
|
+
username,
|
|
119
|
+
role,
|
|
120
|
+
addedAt: new Date().toISOString(),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
data.users.push(user);
|
|
124
|
+
saveUsers(data);
|
|
125
|
+
|
|
126
|
+
return user;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Remove a user
|
|
131
|
+
*/
|
|
132
|
+
function removeUser(username) {
|
|
133
|
+
const data = getUsers();
|
|
134
|
+
|
|
135
|
+
const index = data.users.findIndex(u => u.username === username);
|
|
136
|
+
if (index === -1) {
|
|
137
|
+
throw new Error(`User '${username}' not found`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Prevent removing last ADMIN
|
|
141
|
+
const user = data.users[index];
|
|
142
|
+
if (user.role === 'ADMIN') {
|
|
143
|
+
const adminCount = data.users.filter(u => u.role === 'ADMIN').length;
|
|
144
|
+
if (adminCount === 1) {
|
|
145
|
+
throw new Error('Cannot remove last ADMIN user');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
data.users.splice(index, 1);
|
|
150
|
+
saveUsers(data);
|
|
151
|
+
|
|
152
|
+
return user;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get current user
|
|
157
|
+
*/
|
|
158
|
+
function getCurrentUser() {
|
|
159
|
+
const currentUsername = os.userInfo().username || 'admin';
|
|
160
|
+
const data = getUsers();
|
|
161
|
+
|
|
162
|
+
let user = data.users.find(u => u.username === currentUsername);
|
|
163
|
+
|
|
164
|
+
// If user doesn't exist, create as ADMIN
|
|
165
|
+
if (!user) {
|
|
166
|
+
user = {
|
|
167
|
+
username: currentUsername,
|
|
168
|
+
role: 'ADMIN',
|
|
169
|
+
addedAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
data.users.push(user);
|
|
172
|
+
saveUsers(data);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return user;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Check if user has permission
|
|
180
|
+
*/
|
|
181
|
+
function hasPermission(permission) {
|
|
182
|
+
const user = getCurrentUser();
|
|
183
|
+
const role = ROLES[user.role];
|
|
184
|
+
|
|
185
|
+
if (!role) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return role.permissions.includes(permission);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Require permission (throws if denied)
|
|
194
|
+
*/
|
|
195
|
+
function requirePermission(permission, action = null) {
|
|
196
|
+
if (!hasPermission(permission)) {
|
|
197
|
+
const user = getCurrentUser();
|
|
198
|
+
const actionMsg = action ? ` to ${action}` : '';
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Permission denied${actionMsg}. Required: ${permission}. Your role: ${user.role}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get role details
|
|
207
|
+
*/
|
|
208
|
+
function getRole(roleName) {
|
|
209
|
+
return ROLES[roleName];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* List all roles
|
|
214
|
+
*/
|
|
215
|
+
function listRoles() {
|
|
216
|
+
return Object.values(ROLES);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reset users (for testing)
|
|
221
|
+
*/
|
|
222
|
+
function resetUsers() {
|
|
223
|
+
if (fs.existsSync(USERS_FILE)) {
|
|
224
|
+
fs.unlinkSync(USERS_FILE);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
ROLES,
|
|
230
|
+
addUser,
|
|
231
|
+
removeUser,
|
|
232
|
+
getUsers,
|
|
233
|
+
getCurrentUser,
|
|
234
|
+
hasPermission,
|
|
235
|
+
requirePermission,
|
|
236
|
+
getRole,
|
|
237
|
+
listRoles,
|
|
238
|
+
resetUsers,
|
|
239
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 11: Multi-Site Management
|
|
3
|
+
* Support multiple sites per install with project organization
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
|
|
10
|
+
const SITES_DIR = path.join(os.homedir(), '.odavl-guardian', 'sites');
|
|
11
|
+
const SITES_FILE = path.join(SITES_DIR, 'sites.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure sites directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureSitesDir() {
|
|
17
|
+
if (!fs.existsSync(SITES_DIR)) {
|
|
18
|
+
fs.mkdirSync(SITES_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get all sites
|
|
24
|
+
*/
|
|
25
|
+
function getSites() {
|
|
26
|
+
ensureSitesDir();
|
|
27
|
+
|
|
28
|
+
if (!fs.existsSync(SITES_FILE)) {
|
|
29
|
+
return { sites: [], projects: {} };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(SITES_FILE, 'utf-8'));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { sites: [], projects: {} };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Save sites
|
|
41
|
+
*/
|
|
42
|
+
function saveSites(data) {
|
|
43
|
+
ensureSitesDir();
|
|
44
|
+
fs.writeFileSync(SITES_FILE, JSON.stringify(data, null, 2), 'utf-8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a site
|
|
49
|
+
*/
|
|
50
|
+
function addSite(name, url, project = 'default') {
|
|
51
|
+
const data = getSites();
|
|
52
|
+
|
|
53
|
+
// Check if site name already exists
|
|
54
|
+
const existing = data.sites.find(s => s.name === name);
|
|
55
|
+
if (existing) {
|
|
56
|
+
throw new Error(`Site '${name}' already exists`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate URL
|
|
60
|
+
try {
|
|
61
|
+
new URL(url);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const site = {
|
|
67
|
+
name,
|
|
68
|
+
url,
|
|
69
|
+
project,
|
|
70
|
+
addedAt: new Date().toISOString(),
|
|
71
|
+
lastScannedAt: null,
|
|
72
|
+
scanCount: 0,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
data.sites.push(site);
|
|
76
|
+
|
|
77
|
+
// Add to project index
|
|
78
|
+
if (!data.projects[project]) {
|
|
79
|
+
data.projects[project] = [];
|
|
80
|
+
}
|
|
81
|
+
data.projects[project].push(name);
|
|
82
|
+
|
|
83
|
+
saveSites(data);
|
|
84
|
+
return site;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Remove a site
|
|
89
|
+
*/
|
|
90
|
+
function removeSite(name) {
|
|
91
|
+
const data = getSites();
|
|
92
|
+
|
|
93
|
+
const index = data.sites.findIndex(s => s.name === name);
|
|
94
|
+
if (index === -1) {
|
|
95
|
+
throw new Error(`Site '${name}' not found`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const site = data.sites[index];
|
|
99
|
+
|
|
100
|
+
// Remove from sites array
|
|
101
|
+
data.sites.splice(index, 1);
|
|
102
|
+
|
|
103
|
+
// Remove from project index
|
|
104
|
+
if (data.projects[site.project]) {
|
|
105
|
+
data.projects[site.project] = data.projects[site.project].filter(n => n !== name);
|
|
106
|
+
|
|
107
|
+
// Clean up empty projects
|
|
108
|
+
if (data.projects[site.project].length === 0) {
|
|
109
|
+
delete data.projects[site.project];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
saveSites(data);
|
|
114
|
+
return site;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get a site by name
|
|
119
|
+
*/
|
|
120
|
+
function getSite(name) {
|
|
121
|
+
const data = getSites();
|
|
122
|
+
return data.sites.find(s => s.name === name);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Update site scan stats
|
|
127
|
+
*/
|
|
128
|
+
function recordSiteScan(name) {
|
|
129
|
+
const data = getSites();
|
|
130
|
+
|
|
131
|
+
const site = data.sites.find(s => s.name === name);
|
|
132
|
+
if (!site) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
site.lastScannedAt = new Date().toISOString();
|
|
137
|
+
site.scanCount += 1;
|
|
138
|
+
|
|
139
|
+
saveSites(data);
|
|
140
|
+
return site;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get sites by project
|
|
145
|
+
*/
|
|
146
|
+
function getSitesByProject(project) {
|
|
147
|
+
const data = getSites();
|
|
148
|
+
return data.sites.filter(s => s.project === project);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* List all projects
|
|
153
|
+
*/
|
|
154
|
+
function listProjects() {
|
|
155
|
+
const data = getSites();
|
|
156
|
+
return Object.keys(data.projects).map(name => ({
|
|
157
|
+
name,
|
|
158
|
+
siteCount: data.projects[name].length,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Reset sites (for testing)
|
|
164
|
+
*/
|
|
165
|
+
function resetSites() {
|
|
166
|
+
if (fs.existsSync(SITES_FILE)) {
|
|
167
|
+
fs.unlinkSync(SITES_FILE);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
addSite,
|
|
173
|
+
removeSite,
|
|
174
|
+
getSite,
|
|
175
|
+
getSites,
|
|
176
|
+
recordSiteScan,
|
|
177
|
+
getSitesByProject,
|
|
178
|
+
listProjects,
|
|
179
|
+
resetSites,
|
|
180
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 10: Feedback System
|
|
3
|
+
* Lightweight feedback capture from users
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
|
|
11
|
+
const FEEDBACK_DIR = path.join(os.homedir(), '.odavl-guardian', 'feedback');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ensure feedback directory exists
|
|
15
|
+
*/
|
|
16
|
+
function ensureFeedbackDir() {
|
|
17
|
+
if (!fs.existsSync(FEEDBACK_DIR)) {
|
|
18
|
+
fs.mkdirSync(FEEDBACK_DIR, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create readline interface for interactive prompts
|
|
24
|
+
*/
|
|
25
|
+
function createPrompt() {
|
|
26
|
+
return readline.createInterface({
|
|
27
|
+
input: process.stdin,
|
|
28
|
+
output: process.stdout,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Ask a question and get response
|
|
34
|
+
*/
|
|
35
|
+
function ask(rl, question) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
rl.question(question, (answer) => {
|
|
38
|
+
resolve(answer.trim());
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run interactive feedback session
|
|
45
|
+
*/
|
|
46
|
+
async function runFeedbackSession() {
|
|
47
|
+
console.log('\n🙏 Thank you for taking time to share feedback!\n');
|
|
48
|
+
console.log('This helps us improve Guardian for everyone.\n');
|
|
49
|
+
|
|
50
|
+
const rl = createPrompt();
|
|
51
|
+
const feedback = {
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
responses: {},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Question 1: What worked?
|
|
58
|
+
console.log('1️⃣ What worked well for you?');
|
|
59
|
+
feedback.responses.whatWorked = await ask(rl, ' → ');
|
|
60
|
+
console.log();
|
|
61
|
+
|
|
62
|
+
// Question 2: What blocked you?
|
|
63
|
+
console.log('2️⃣ What blocked you or was frustrating?');
|
|
64
|
+
feedback.responses.whatBlocked = await ask(rl, ' → ');
|
|
65
|
+
console.log();
|
|
66
|
+
|
|
67
|
+
// Question 3: Would you recommend?
|
|
68
|
+
console.log('3️⃣ Would you recommend Guardian to others? (yes/no)');
|
|
69
|
+
const recommend = await ask(rl, ' → ');
|
|
70
|
+
feedback.responses.wouldRecommend = recommend.toLowerCase().startsWith('y') ? 'yes' : 'no';
|
|
71
|
+
console.log();
|
|
72
|
+
|
|
73
|
+
// Optional: Email
|
|
74
|
+
console.log('📧 (Optional) Share your email if you\'d like updates:');
|
|
75
|
+
const email = await ask(rl, ' → ');
|
|
76
|
+
if (email && email.includes('@')) {
|
|
77
|
+
feedback.email = email;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
rl.close();
|
|
81
|
+
|
|
82
|
+
// Save feedback
|
|
83
|
+
saveFeedback(feedback);
|
|
84
|
+
|
|
85
|
+
console.log('\n✅ Feedback saved! Thank you for helping improve Guardian.\n');
|
|
86
|
+
|
|
87
|
+
return feedback;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
rl.close();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Save feedback to local file
|
|
96
|
+
*/
|
|
97
|
+
function saveFeedback(feedback) {
|
|
98
|
+
ensureFeedbackDir();
|
|
99
|
+
|
|
100
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
101
|
+
const filename = `feedback-${timestamp}.json`;
|
|
102
|
+
const filepath = path.join(FEEDBACK_DIR, filename);
|
|
103
|
+
|
|
104
|
+
fs.writeFileSync(filepath, JSON.stringify(feedback, null, 2), 'utf-8');
|
|
105
|
+
|
|
106
|
+
return filepath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get all feedback submissions
|
|
111
|
+
*/
|
|
112
|
+
function getAllFeedback() {
|
|
113
|
+
ensureFeedbackDir();
|
|
114
|
+
|
|
115
|
+
const files = fs.readdirSync(FEEDBACK_DIR)
|
|
116
|
+
.filter(f => f.startsWith('feedback-') && f.endsWith('.json'))
|
|
117
|
+
.sort()
|
|
118
|
+
.reverse();
|
|
119
|
+
|
|
120
|
+
return files.map(filename => {
|
|
121
|
+
const filepath = path.join(FEEDBACK_DIR, filename);
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}).filter(Boolean);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get feedback count
|
|
132
|
+
*/
|
|
133
|
+
function getFeedbackCount() {
|
|
134
|
+
const feedback = getAllFeedback();
|
|
135
|
+
return feedback.length;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Clear all feedback (for testing)
|
|
140
|
+
*/
|
|
141
|
+
function clearFeedback() {
|
|
142
|
+
if (fs.existsSync(FEEDBACK_DIR)) {
|
|
143
|
+
const files = fs.readdirSync(FEEDBACK_DIR);
|
|
144
|
+
files.forEach(file => {
|
|
145
|
+
fs.unlinkSync(path.join(FEEDBACK_DIR, file));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
runFeedbackSession,
|
|
152
|
+
saveFeedback,
|
|
153
|
+
getAllFeedback,
|
|
154
|
+
getFeedbackCount,
|
|
155
|
+
clearFeedback,
|
|
156
|
+
};
|