clawsecure 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/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/clawsecure.js +84 -0
- package/package.json +48 -0
- package/skill/.clawsecure-version +1 -0
- package/skill/HEARTBEAT.md +18 -0
- package/skill/README.md +146 -0
- package/skill/SKILL.md +83 -0
- package/skill/references/commands.md +40 -0
- package/skill/references/config-audit-checklist.md +81 -0
- package/skill/references/mcp-risk-classifications.md +43 -0
- package/skill/references/onboarding.md +48 -0
- package/skill/references/response-templates.md +102 -0
- package/skill/references/secure-install-guide.md +91 -0
- package/src/api-client.js +227 -0
- package/src/component-scanner.js +238 -0
- package/src/config-parser.js +352 -0
- package/src/daemon.js +452 -0
- package/src/logger.js +60 -0
- package/src/metadata-stripper.js +181 -0
- package/src/process-manager.js +220 -0
- package/src/session-parser.js +241 -0
- package/src/skill-installer.js +199 -0
- package/src/sync-manager.js +246 -0
- package/src/threat-intel.js +180 -0
- package/src/watcher.js +155 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const apiClientModule = require('./api-client');
|
|
4
|
+
const threatIntel = require('./threat-intel');
|
|
5
|
+
const processManager = require('./process-manager');
|
|
6
|
+
const logger = require('./logger');
|
|
7
|
+
|
|
8
|
+
const MAX_OFFLINE_QUEUE = 100;
|
|
9
|
+
const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
const RECONNECT_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
11
|
+
|
|
12
|
+
// Module state
|
|
13
|
+
let apiClient = null;
|
|
14
|
+
let envId = null;
|
|
15
|
+
let isOnline = false;
|
|
16
|
+
let offlineQueue = [];
|
|
17
|
+
let heartbeatTimer = null;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Connect to the ClawSecure API and fetch tier config.
|
|
21
|
+
* @returns {Promise<{ tier: string, envId: string|null, online: boolean }>}
|
|
22
|
+
*/
|
|
23
|
+
async function connect() {
|
|
24
|
+
const token = processManager.getToken();
|
|
25
|
+
const baseUrl = processManager.getApiUrl();
|
|
26
|
+
|
|
27
|
+
if (!token) {
|
|
28
|
+
logger.warn('No API token configured. Run "clawsecure setup" to connect your account.');
|
|
29
|
+
logger.warn('Running in offline mode. Local monitoring is active.');
|
|
30
|
+
isOnline = false;
|
|
31
|
+
return { tier: detectTierFallback(), envId: null, online: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
apiClient = apiClientModule.create({ baseUrl, token });
|
|
35
|
+
logger.info(`Connecting to ClawSecure API at ${baseUrl}...`);
|
|
36
|
+
|
|
37
|
+
const config = await apiClient.fetchConfig();
|
|
38
|
+
if (config) {
|
|
39
|
+
envId = config.envId || null;
|
|
40
|
+
isOnline = true;
|
|
41
|
+
logger.success('API connected');
|
|
42
|
+
return { tier: config.tier || 'shield', envId, online: true };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
logger.warn('API unreachable. Running in offline mode. Data will sync when connection is restored.');
|
|
46
|
+
isOnline = false;
|
|
47
|
+
scheduleReconnect();
|
|
48
|
+
return { tier: detectTierFallback(), envId: null, online: false };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fallback tier detection via environment variable.
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function detectTierFallback() {
|
|
56
|
+
const envTier = process.env.CLAWSECURE_TIER;
|
|
57
|
+
if (envTier && envTier.toLowerCase() === 'sentinel') return 'sentinel';
|
|
58
|
+
return 'shield';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Send environment sync data to the API, or queue if offline.
|
|
63
|
+
* @param {object} payload
|
|
64
|
+
*/
|
|
65
|
+
function syncEnvironment(payload) {
|
|
66
|
+
if (isOnline && apiClient && envId) {
|
|
67
|
+
apiClient.syncEnvironment(envId, payload).catch((err) => {
|
|
68
|
+
logger.debug(`Sync failed, queueing: ${err.message}`);
|
|
69
|
+
queueOffline({ type: 'sync', payload });
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
queueOffline({ type: 'sync', payload });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Send tool call data to the API, or queue if offline.
|
|
78
|
+
* @param {Array<object>} toolCalls
|
|
79
|
+
*/
|
|
80
|
+
function sendToolCalls(toolCalls) {
|
|
81
|
+
if (isOnline && apiClient && envId) {
|
|
82
|
+
apiClient.sendToolCalls(envId, toolCalls).catch((err) => {
|
|
83
|
+
logger.debug(`Tool call send failed, queueing: ${err.message}`);
|
|
84
|
+
queueOffline({ type: 'toolcalls', payload: toolCalls });
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
queueOffline({ type: 'toolcalls', payload: toolCalls });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Send initial environment state to the API.
|
|
93
|
+
* @param {Array<object>} components
|
|
94
|
+
* @param {Map<string, string>} snapshot
|
|
95
|
+
* @returns {Promise<boolean>}
|
|
96
|
+
*/
|
|
97
|
+
async function sendInitialSync(components, snapshot) {
|
|
98
|
+
if (!isOnline || !apiClient || !envId) return false;
|
|
99
|
+
|
|
100
|
+
const syncPayload = {
|
|
101
|
+
components: components.map((c) => ({
|
|
102
|
+
name: c.name, type: c.type, source: c.source,
|
|
103
|
+
enabled: c.enabled, hash: c.hash || null
|
|
104
|
+
})),
|
|
105
|
+
snapshot: Object.fromEntries(snapshot)
|
|
106
|
+
};
|
|
107
|
+
const synced = await apiClient.syncEnvironment(envId, syncPayload);
|
|
108
|
+
if (synced) logger.success('Initial environment sync complete');
|
|
109
|
+
return synced;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load threat intelligence from the API.
|
|
114
|
+
* @returns {Promise<boolean>}
|
|
115
|
+
*/
|
|
116
|
+
async function loadThreats() {
|
|
117
|
+
if (!isOnline || !apiClient) return false;
|
|
118
|
+
const loaded = await threatIntel.load(apiClient);
|
|
119
|
+
if (loaded) threatIntel.startAutoRefresh();
|
|
120
|
+
return loaded;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Add an event to the offline queue.
|
|
125
|
+
* @param {object} event
|
|
126
|
+
*/
|
|
127
|
+
function queueOffline(event) {
|
|
128
|
+
if (offlineQueue.length >= MAX_OFFLINE_QUEUE) {
|
|
129
|
+
offlineQueue.shift();
|
|
130
|
+
logger.debug('Offline queue full, dropping oldest event');
|
|
131
|
+
}
|
|
132
|
+
offlineQueue.push(event);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Flush the offline queue to the API.
|
|
137
|
+
*/
|
|
138
|
+
async function flushOfflineQueue() {
|
|
139
|
+
if (offlineQueue.length === 0) return;
|
|
140
|
+
logger.info(`Flushing ${offlineQueue.length} queued events...`);
|
|
141
|
+
|
|
142
|
+
const queue = offlineQueue.slice();
|
|
143
|
+
offlineQueue = [];
|
|
144
|
+
|
|
145
|
+
for (const event of queue) {
|
|
146
|
+
try {
|
|
147
|
+
if (event.type === 'sync') {
|
|
148
|
+
await apiClient.syncEnvironment(envId, event.payload);
|
|
149
|
+
} else if (event.type === 'toolcalls') {
|
|
150
|
+
await apiClient.sendToolCalls(envId, event.payload);
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logger.debug(`Failed to flush event: ${err.message}`);
|
|
154
|
+
offlineQueue.push(event);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Schedule periodic reconnection attempts.
|
|
162
|
+
*/
|
|
163
|
+
function scheduleReconnect() {
|
|
164
|
+
const timer = setInterval(async () => {
|
|
165
|
+
if (isOnline) {
|
|
166
|
+
clearInterval(timer);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
logger.debug('Attempting API reconnection...');
|
|
170
|
+
const token = processManager.getToken();
|
|
171
|
+
if (!token) return;
|
|
172
|
+
|
|
173
|
+
const baseUrl = processManager.getApiUrl();
|
|
174
|
+
if (!apiClient) {
|
|
175
|
+
apiClient = apiClientModule.create({ baseUrl, token });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const config = await apiClient.fetchConfig();
|
|
179
|
+
if (config) {
|
|
180
|
+
envId = config.envId || null;
|
|
181
|
+
isOnline = true;
|
|
182
|
+
clearInterval(timer);
|
|
183
|
+
logger.success('API reconnected');
|
|
184
|
+
await threatIntel.load(apiClient);
|
|
185
|
+
threatIntel.startAutoRefresh();
|
|
186
|
+
await flushOfflineQueue();
|
|
187
|
+
}
|
|
188
|
+
}, RECONNECT_INTERVAL_MS);
|
|
189
|
+
|
|
190
|
+
if (timer.unref) timer.unref();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Start the heartbeat interval.
|
|
195
|
+
* @param {function} getStatus Callback that returns current daemon status
|
|
196
|
+
*/
|
|
197
|
+
function startHeartbeat(getStatus) {
|
|
198
|
+
if (heartbeatTimer) return;
|
|
199
|
+
heartbeatTimer = setInterval(() => {
|
|
200
|
+
if (isOnline && apiClient && envId) {
|
|
201
|
+
const status = getStatus ? getStatus() : {};
|
|
202
|
+
apiClient.syncEnvironment(envId, {
|
|
203
|
+
heartbeat: true,
|
|
204
|
+
...status
|
|
205
|
+
}).catch((err) => {
|
|
206
|
+
logger.debug(`Heartbeat failed: ${err.message}`);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
210
|
+
|
|
211
|
+
if (heartbeatTimer.unref) heartbeatTimer.unref();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Clean up all sync resources.
|
|
216
|
+
*/
|
|
217
|
+
function cleanup() {
|
|
218
|
+
if (heartbeatTimer) {
|
|
219
|
+
clearInterval(heartbeatTimer);
|
|
220
|
+
heartbeatTimer = null;
|
|
221
|
+
}
|
|
222
|
+
threatIntel.stopAutoRefresh();
|
|
223
|
+
apiClient = null;
|
|
224
|
+
envId = null;
|
|
225
|
+
isOnline = false;
|
|
226
|
+
offlineQueue = [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get current connection state.
|
|
231
|
+
* @returns {{ online: boolean, queueSize: number }}
|
|
232
|
+
*/
|
|
233
|
+
function getState() {
|
|
234
|
+
return { online: isOnline, queueSize: offlineQueue.length };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
connect,
|
|
239
|
+
syncEnvironment,
|
|
240
|
+
sendToolCalls,
|
|
241
|
+
sendInitialSync,
|
|
242
|
+
loadThreats,
|
|
243
|
+
startHeartbeat,
|
|
244
|
+
cleanup,
|
|
245
|
+
getState
|
|
246
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('./logger');
|
|
4
|
+
|
|
5
|
+
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
6
|
+
|
|
7
|
+
let cachedThreats = null;
|
|
8
|
+
let refreshTimer = null;
|
|
9
|
+
let apiClientRef = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Load threat intelligence data from the API.
|
|
13
|
+
* @param {object} apiClient API client instance
|
|
14
|
+
* @returns {Promise<boolean>} Whether load succeeded
|
|
15
|
+
*/
|
|
16
|
+
async function load(apiClient) {
|
|
17
|
+
apiClientRef = apiClient;
|
|
18
|
+
|
|
19
|
+
const data = await apiClient.fetchThreats();
|
|
20
|
+
if (data) {
|
|
21
|
+
cachedThreats = normalizeThreatData(data);
|
|
22
|
+
logger.success(
|
|
23
|
+
`Threat intelligence loaded: ${cachedThreats.components.size} known threats, ` +
|
|
24
|
+
`${cachedThreats.hashes.size} known malicious hashes`
|
|
25
|
+
);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
logger.warn('Could not load threat intelligence. Will retry on next refresh.');
|
|
30
|
+
cachedThreats = emptyThreatCache();
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start periodic threat intelligence refresh.
|
|
36
|
+
*/
|
|
37
|
+
function startAutoRefresh() {
|
|
38
|
+
if (refreshTimer) return;
|
|
39
|
+
refreshTimer = setInterval(async () => {
|
|
40
|
+
await refresh();
|
|
41
|
+
}, REFRESH_INTERVAL_MS);
|
|
42
|
+
|
|
43
|
+
// Unref so it doesn't keep the process alive
|
|
44
|
+
if (refreshTimer.unref) refreshTimer.unref();
|
|
45
|
+
logger.debug('Threat intelligence auto-refresh enabled (every 6 hours)');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Refresh threat intelligence from the API.
|
|
50
|
+
* @returns {Promise<boolean>}
|
|
51
|
+
*/
|
|
52
|
+
async function refresh() {
|
|
53
|
+
if (!apiClientRef) {
|
|
54
|
+
logger.debug('No API client available for threat refresh');
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
logger.info('Refreshing threat intelligence...');
|
|
59
|
+
const data = await apiClientRef.fetchThreats();
|
|
60
|
+
if (data) {
|
|
61
|
+
cachedThreats = normalizeThreatData(data);
|
|
62
|
+
logger.success('Threat intelligence refreshed');
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
logger.warn('Threat intelligence refresh failed, using cached data');
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Stop auto-refresh timer.
|
|
72
|
+
*/
|
|
73
|
+
function stopAutoRefresh() {
|
|
74
|
+
if (refreshTimer) {
|
|
75
|
+
clearInterval(refreshTimer);
|
|
76
|
+
refreshTimer = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a component is a known threat.
|
|
82
|
+
* @param {string} componentName
|
|
83
|
+
* @param {string|null} hash SHA-256 hash
|
|
84
|
+
* @returns {{ isThreat: boolean, reason: string|null }}
|
|
85
|
+
*/
|
|
86
|
+
function isKnownThreat(componentName, hash) {
|
|
87
|
+
if (!cachedThreats) {
|
|
88
|
+
return { isThreat: false, reason: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check by name
|
|
92
|
+
const nameKey = componentName.toLowerCase();
|
|
93
|
+
if (cachedThreats.components.has(nameKey)) {
|
|
94
|
+
return {
|
|
95
|
+
isThreat: true,
|
|
96
|
+
reason: cachedThreats.components.get(nameKey)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check by hash
|
|
101
|
+
if (hash && cachedThreats.hashes.has(hash)) {
|
|
102
|
+
return {
|
|
103
|
+
isThreat: true,
|
|
104
|
+
reason: cachedThreats.hashes.get(hash)
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { isThreat: false, reason: null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get the current cached threat data.
|
|
113
|
+
* @returns {object|null}
|
|
114
|
+
*/
|
|
115
|
+
function getThreats() {
|
|
116
|
+
return cachedThreats;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Normalize raw threat API response into lookup-friendly structure.
|
|
121
|
+
* @param {object} data Raw API response
|
|
122
|
+
* @returns {object} Normalized cache
|
|
123
|
+
*/
|
|
124
|
+
function normalizeThreatData(data) {
|
|
125
|
+
const cache = emptyThreatCache();
|
|
126
|
+
|
|
127
|
+
// Known bad components (by name)
|
|
128
|
+
if (Array.isArray(data.components)) {
|
|
129
|
+
for (const item of data.components) {
|
|
130
|
+
if (item.name) {
|
|
131
|
+
cache.components.set(item.name.toLowerCase(), item.reason || 'Known threat');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Known malicious hashes
|
|
137
|
+
if (Array.isArray(data.hashes)) {
|
|
138
|
+
for (const item of data.hashes) {
|
|
139
|
+
if (item.hash) {
|
|
140
|
+
cache.hashes.set(item.hash, item.reason || 'Malicious hash');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Metadata
|
|
146
|
+
cache.updatedAt = data.updatedAt || new Date().toISOString();
|
|
147
|
+
|
|
148
|
+
return cache;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create an empty threat cache.
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
function emptyThreatCache() {
|
|
156
|
+
return {
|
|
157
|
+
components: new Map(),
|
|
158
|
+
hashes: new Map(),
|
|
159
|
+
updatedAt: null
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reset all state (for testing/cleanup).
|
|
165
|
+
*/
|
|
166
|
+
function reset() {
|
|
167
|
+
stopAutoRefresh();
|
|
168
|
+
cachedThreats = null;
|
|
169
|
+
apiClientRef = null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
load,
|
|
174
|
+
startAutoRefresh,
|
|
175
|
+
stopAutoRefresh,
|
|
176
|
+
refresh,
|
|
177
|
+
isKnownThreat,
|
|
178
|
+
getThreats,
|
|
179
|
+
reset
|
|
180
|
+
};
|
package/src/watcher.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const chokidar = require('chokidar');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_DEBOUNCE_MS = 500;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates a file watcher on the given directories.
|
|
11
|
+
* Debounces rapid changes into batched callbacks.
|
|
12
|
+
*
|
|
13
|
+
* @param {string[]} dirs Directories to watch
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {number} [options.debounceMs=500] Debounce interval in ms
|
|
16
|
+
* @param {boolean} [options.ignoreInitial=true] Skip initial add events
|
|
17
|
+
* @returns {object} Watcher controller with onFileChange(), close()
|
|
18
|
+
*/
|
|
19
|
+
function createWatcher(dirs, options) {
|
|
20
|
+
const opts = Object.assign({ debounceMs: DEFAULT_DEBOUNCE_MS, ignoreInitial: true }, options);
|
|
21
|
+
|
|
22
|
+
// Filter to only directories that exist
|
|
23
|
+
const validDirs = dirs.filter((dir) => {
|
|
24
|
+
try {
|
|
25
|
+
require('fs').accessSync(dir);
|
|
26
|
+
return true;
|
|
27
|
+
} catch (e) {
|
|
28
|
+
logger.debug(`Skipping non-existent watch dir: ${dir}`);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (validDirs.length === 0) {
|
|
34
|
+
logger.warn('No valid directories to watch.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
logger.info(`Watching ${validDirs.length} director${validDirs.length === 1 ? 'y' : 'ies'} for changes`);
|
|
38
|
+
for (const dir of validDirs) {
|
|
39
|
+
logger.debug(` Watching: ${dir}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const watcher = chokidar.watch(validDirs, {
|
|
43
|
+
persistent: true,
|
|
44
|
+
ignoreInitial: opts.ignoreInitial,
|
|
45
|
+
followSymlinks: false,
|
|
46
|
+
depth: 5,
|
|
47
|
+
ignored: [
|
|
48
|
+
'**/node_modules/**',
|
|
49
|
+
'**/.git/**',
|
|
50
|
+
'**/.DS_Store'
|
|
51
|
+
],
|
|
52
|
+
awaitWriteFinish: {
|
|
53
|
+
stabilityThreshold: 200,
|
|
54
|
+
pollInterval: 50
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
let changeBuffer = [];
|
|
59
|
+
let debounceTimer = null;
|
|
60
|
+
let changeCallback = null;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Internal handler for all file events.
|
|
64
|
+
* Buffers events and flushes after debounce interval.
|
|
65
|
+
*/
|
|
66
|
+
function handleEvent(eventType, filePath) {
|
|
67
|
+
logger.debug(`File ${eventType}: ${filePath}`);
|
|
68
|
+
|
|
69
|
+
changeBuffer.push({
|
|
70
|
+
type: eventType,
|
|
71
|
+
path: filePath,
|
|
72
|
+
dir: path.dirname(filePath),
|
|
73
|
+
timestamp: Date.now()
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Reset debounce timer
|
|
77
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
78
|
+
debounceTimer = setTimeout(flushChanges, opts.debounceMs);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Flush buffered changes to the registered callback.
|
|
83
|
+
*/
|
|
84
|
+
function flushChanges() {
|
|
85
|
+
if (changeBuffer.length === 0) return;
|
|
86
|
+
if (!changeCallback) return;
|
|
87
|
+
|
|
88
|
+
const events = changeBuffer.slice();
|
|
89
|
+
changeBuffer = [];
|
|
90
|
+
debounceTimer = null;
|
|
91
|
+
|
|
92
|
+
// Deduplicate: keep latest event per file path
|
|
93
|
+
const seen = new Map();
|
|
94
|
+
for (const evt of events) {
|
|
95
|
+
seen.set(evt.path, evt);
|
|
96
|
+
}
|
|
97
|
+
const deduped = Array.from(seen.values());
|
|
98
|
+
|
|
99
|
+
logger.info(`Detected ${deduped.length} file change${deduped.length === 1 ? '' : 's'}`);
|
|
100
|
+
try {
|
|
101
|
+
changeCallback(deduped);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
logger.error(`Error in change handler: ${err.message}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Register chokidar events
|
|
108
|
+
watcher.on('add', (fp) => handleEvent('add', fp));
|
|
109
|
+
watcher.on('change', (fp) => handleEvent('change', fp));
|
|
110
|
+
watcher.on('unlink', (fp) => handleEvent('unlink', fp));
|
|
111
|
+
watcher.on('error', (err) => logger.error(`Watcher error: ${err.message}`));
|
|
112
|
+
|
|
113
|
+
watcher.on('ready', () => {
|
|
114
|
+
logger.success('File watcher ready');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
/**
|
|
119
|
+
* Register a callback for batched file changes.
|
|
120
|
+
* @param {function(Array<{type: string, path: string, dir: string, timestamp: number}>)} callback
|
|
121
|
+
*/
|
|
122
|
+
onFileChange(callback) {
|
|
123
|
+
changeCallback = callback;
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Gracefully close the watcher and flush pending changes.
|
|
128
|
+
* @returns {Promise<void>}
|
|
129
|
+
*/
|
|
130
|
+
async close() {
|
|
131
|
+
if (debounceTimer) {
|
|
132
|
+
clearTimeout(debounceTimer);
|
|
133
|
+
debounceTimer = null;
|
|
134
|
+
}
|
|
135
|
+
// Flush any remaining buffered changes
|
|
136
|
+
if (changeBuffer.length > 0 && changeCallback) {
|
|
137
|
+
flushChanges();
|
|
138
|
+
}
|
|
139
|
+
await watcher.close();
|
|
140
|
+
logger.debug('File watcher closed');
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Get the list of watched directories.
|
|
145
|
+
* @returns {object}
|
|
146
|
+
*/
|
|
147
|
+
getWatched() {
|
|
148
|
+
return watcher.getWatched();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = {
|
|
154
|
+
createWatcher
|
|
155
|
+
};
|