@chrisromp/copilot-bridge 0.6.0-dev.2
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 +93 -0
- package/bin/copilot-bridge.js +61 -0
- package/config.sample.json +100 -0
- package/dist/channels/mattermost/adapter.d.ts +55 -0
- package/dist/channels/mattermost/adapter.d.ts.map +1 -0
- package/dist/channels/mattermost/adapter.js +524 -0
- package/dist/channels/mattermost/adapter.js.map +1 -0
- package/dist/channels/mattermost/streaming.d.ts +29 -0
- package/dist/channels/mattermost/streaming.d.ts.map +1 -0
- package/dist/channels/mattermost/streaming.js +151 -0
- package/dist/channels/mattermost/streaming.js.map +1 -0
- package/dist/config.d.ts +107 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +817 -0
- package/dist/config.js.map +1 -0
- package/dist/core/bridge.d.ts +73 -0
- package/dist/core/bridge.d.ts.map +1 -0
- package/dist/core/bridge.js +166 -0
- package/dist/core/bridge.js.map +1 -0
- package/dist/core/channel-idle.d.ts +40 -0
- package/dist/core/channel-idle.d.ts.map +1 -0
- package/dist/core/channel-idle.js +120 -0
- package/dist/core/channel-idle.js.map +1 -0
- package/dist/core/command-handler.d.ts +51 -0
- package/dist/core/command-handler.d.ts.map +1 -0
- package/dist/core/command-handler.js +393 -0
- package/dist/core/command-handler.js.map +1 -0
- package/dist/core/inter-agent.d.ts +52 -0
- package/dist/core/inter-agent.d.ts.map +1 -0
- package/dist/core/inter-agent.js +179 -0
- package/dist/core/inter-agent.js.map +1 -0
- package/dist/core/onboarding.d.ts +44 -0
- package/dist/core/onboarding.d.ts.map +1 -0
- package/dist/core/onboarding.js +205 -0
- package/dist/core/onboarding.js.map +1 -0
- package/dist/core/scheduler.d.ts +38 -0
- package/dist/core/scheduler.d.ts.map +1 -0
- package/dist/core/scheduler.js +253 -0
- package/dist/core/scheduler.js.map +1 -0
- package/dist/core/session-manager.d.ts +166 -0
- package/dist/core/session-manager.d.ts.map +1 -0
- package/dist/core/session-manager.js +1732 -0
- package/dist/core/session-manager.js.map +1 -0
- package/dist/core/stream-formatter.d.ts +14 -0
- package/dist/core/stream-formatter.d.ts.map +1 -0
- package/dist/core/stream-formatter.js +198 -0
- package/dist/core/stream-formatter.js.map +1 -0
- package/dist/core/thread-utils.d.ts +22 -0
- package/dist/core/thread-utils.d.ts.map +1 -0
- package/dist/core/thread-utils.js +44 -0
- package/dist/core/thread-utils.js.map +1 -0
- package/dist/core/workspace-manager.d.ts +38 -0
- package/dist/core/workspace-manager.d.ts.map +1 -0
- package/dist/core/workspace-manager.js +230 -0
- package/dist/core/workspace-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1286 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +34 -0
- package/dist/logger.js.map +1 -0
- package/dist/state/store.d.ts +124 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +523 -0
- package/dist/state/store.js.map +1 -0
- package/dist/types.d.ts +185 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
- package/scripts/check.ts +267 -0
- package/scripts/com.copilot-bridge.plist +41 -0
- package/scripts/copilot-bridge.service +30 -0
- package/scripts/init.ts +250 -0
- package/scripts/install-service.ts +123 -0
- package/scripts/lib/config-gen.ts +129 -0
- package/scripts/lib/mattermost.ts +109 -0
- package/scripts/lib/output.ts +69 -0
- package/scripts/lib/prerequisites.ts +86 -0
- package/scripts/lib/prompts.ts +65 -0
- package/scripts/lib/service.ts +191 -0
- package/scripts/uninstall-service.ts +90 -0
- package/templates/admin/AGENTS.md +325 -0
- package/templates/admin/MEMORY.md +4 -0
- package/templates/agents/AGENTS.md +97 -0
- package/templates/agents/MEMORY.md +4 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getDynamicChannel } from './state/store.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
const log = createLogger('config');
|
|
7
|
+
let _config = null;
|
|
8
|
+
let _configPath = null;
|
|
9
|
+
// Dynamic channels registered at runtime (DMs, onboarded projects).
|
|
10
|
+
// Kept separate from _config so they survive reloads.
|
|
11
|
+
const _dynamicChannels = new Map();
|
|
12
|
+
/** Validate raw config JSON and normalize into an AppConfig. Throws on invalid input. */
|
|
13
|
+
function validateAndNormalize(raw) {
|
|
14
|
+
// Validate platforms
|
|
15
|
+
if (!raw.platforms || typeof raw.platforms !== 'object') {
|
|
16
|
+
throw new Error('Config must have a "platforms" object');
|
|
17
|
+
}
|
|
18
|
+
for (const [name, platform] of Object.entries(raw.platforms)) {
|
|
19
|
+
const p = platform;
|
|
20
|
+
if (!p.url)
|
|
21
|
+
throw new Error(`Platform "${name}" missing "url"`);
|
|
22
|
+
if (!p.botToken && !p.bots)
|
|
23
|
+
throw new Error(`Platform "${name}" needs either "botToken" or "bots"`);
|
|
24
|
+
if (p.bots) {
|
|
25
|
+
for (const [botName, bot] of Object.entries(p.bots)) {
|
|
26
|
+
if (!bot.token)
|
|
27
|
+
throw new Error(`Platform "${name}" bot "${botName}" missing "token"`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Validate channels (empty is allowed — DMs are auto-discovered)
|
|
32
|
+
if (!Array.isArray(raw.channels)) {
|
|
33
|
+
raw.channels = [];
|
|
34
|
+
}
|
|
35
|
+
for (const ch of raw.channels) {
|
|
36
|
+
if (!ch.id)
|
|
37
|
+
throw new Error('Each channel must have an "id"');
|
|
38
|
+
if (!ch.platform)
|
|
39
|
+
throw new Error(`Channel "${ch.id}" missing "platform"`);
|
|
40
|
+
if (!ch.workingDirectory)
|
|
41
|
+
throw new Error(`Channel "${ch.id}" missing "workingDirectory"`);
|
|
42
|
+
if (!raw.platforms[ch.platform]) {
|
|
43
|
+
throw new Error(`Channel "${ch.id}" references unknown platform "${ch.platform}"`);
|
|
44
|
+
}
|
|
45
|
+
const plat = raw.platforms[ch.platform];
|
|
46
|
+
if (ch.bot && plat.bots && !plat.bots[ch.bot]) {
|
|
47
|
+
throw new Error(`Channel "${ch.id}" references unknown bot "${ch.bot}" (available: ${Object.keys(plat.bots).join(', ')})`);
|
|
48
|
+
}
|
|
49
|
+
if (!fs.existsSync(ch.workingDirectory)) {
|
|
50
|
+
console.warn(`Warning: workingDirectory "${ch.workingDirectory}" for channel "${ch.id}" does not exist`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Validate interAgent config (optional)
|
|
54
|
+
if (raw.interAgent) {
|
|
55
|
+
const ia = raw.interAgent;
|
|
56
|
+
if (typeof ia.enabled !== 'boolean') {
|
|
57
|
+
throw new Error('interAgent.enabled must be a boolean');
|
|
58
|
+
}
|
|
59
|
+
if (ia.defaultTimeout !== undefined && (typeof ia.defaultTimeout !== 'number' || ia.defaultTimeout <= 0)) {
|
|
60
|
+
throw new Error('interAgent.defaultTimeout must be a positive number');
|
|
61
|
+
}
|
|
62
|
+
if (ia.maxTimeout !== undefined && (typeof ia.maxTimeout !== 'number' || ia.maxTimeout <= 0)) {
|
|
63
|
+
throw new Error('interAgent.maxTimeout must be a positive number');
|
|
64
|
+
}
|
|
65
|
+
if (ia.maxDepth !== undefined && (typeof ia.maxDepth !== 'number' || ia.maxDepth < 1 || !Number.isInteger(ia.maxDepth))) {
|
|
66
|
+
throw new Error('interAgent.maxDepth must be a positive integer');
|
|
67
|
+
}
|
|
68
|
+
if (ia.allow) {
|
|
69
|
+
if (typeof ia.allow !== 'object' || Array.isArray(ia.allow)) {
|
|
70
|
+
throw new Error('interAgent.allow must be an object mapping bot names to permissions');
|
|
71
|
+
}
|
|
72
|
+
for (const [botName, perms] of Object.entries(ia.allow)) {
|
|
73
|
+
const p = perms;
|
|
74
|
+
if (p.canCall && !Array.isArray(p.canCall)) {
|
|
75
|
+
throw new Error(`interAgent.allow.${botName}.canCall must be an array`);
|
|
76
|
+
}
|
|
77
|
+
if (p.canBeCalledBy && !Array.isArray(p.canBeCalledBy)) {
|
|
78
|
+
throw new Error(`interAgent.allow.${botName}.canBeCalledBy must be an array`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Apply defaults
|
|
84
|
+
const defaults = {
|
|
85
|
+
model: 'claude-sonnet-4.6',
|
|
86
|
+
agent: null,
|
|
87
|
+
triggerMode: 'mention',
|
|
88
|
+
threadedReplies: true,
|
|
89
|
+
verbose: false,
|
|
90
|
+
permissionMode: 'interactive',
|
|
91
|
+
...raw.defaults,
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
platforms: raw.platforms,
|
|
95
|
+
channels: raw.channels,
|
|
96
|
+
defaults,
|
|
97
|
+
permissions: raw.permissions,
|
|
98
|
+
interAgent: raw.interAgent,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function loadConfig(configPath) {
|
|
102
|
+
const filePath = configPath
|
|
103
|
+
?? process.env.COPILOT_BRIDGE_CONFIG
|
|
104
|
+
?? (fs.existsSync(path.join(os.homedir(), '.copilot-bridge', 'config.json'))
|
|
105
|
+
? path.join(os.homedir(), '.copilot-bridge', 'config.json')
|
|
106
|
+
: path.join(process.cwd(), 'config.json'));
|
|
107
|
+
if (!fs.existsSync(filePath)) {
|
|
108
|
+
throw new Error(`Config file not found: ${filePath}. Copy config.sample.json to config.json and edit it.`);
|
|
109
|
+
}
|
|
110
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
111
|
+
_config = validateAndNormalize(raw);
|
|
112
|
+
_configPath = filePath;
|
|
113
|
+
return _config;
|
|
114
|
+
}
|
|
115
|
+
/** The resolved config file path (available after loadConfig). */
|
|
116
|
+
export function getConfigPath() {
|
|
117
|
+
return _configPath;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Diff two config objects and return human-readable change descriptions.
|
|
121
|
+
* Also flags fields that require a restart to take effect.
|
|
122
|
+
*/
|
|
123
|
+
function diffConfigs(oldCfg, newCfg) {
|
|
124
|
+
const changes = [];
|
|
125
|
+
const restartNeeded = [];
|
|
126
|
+
// --- Defaults ---
|
|
127
|
+
for (const key of Object.keys({ ...oldCfg.defaults, ...newCfg.defaults })) {
|
|
128
|
+
const ov = oldCfg.defaults[key];
|
|
129
|
+
const nv = newCfg.defaults[key];
|
|
130
|
+
if (JSON.stringify(ov) !== JSON.stringify(nv)) {
|
|
131
|
+
changes.push(`defaults.${key}: ${JSON.stringify(ov)} → ${JSON.stringify(nv)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// --- Permissions ---
|
|
135
|
+
if (JSON.stringify(oldCfg.permissions ?? {}) !== JSON.stringify(newCfg.permissions ?? {})) {
|
|
136
|
+
changes.push('permissions updated');
|
|
137
|
+
}
|
|
138
|
+
// --- Inter-Agent ---
|
|
139
|
+
if (JSON.stringify(oldCfg.interAgent ?? {}) !== JSON.stringify(newCfg.interAgent ?? {})) {
|
|
140
|
+
changes.push('interAgent config updated');
|
|
141
|
+
}
|
|
142
|
+
// --- Channels ---
|
|
143
|
+
const oldChannelMap = new Map(oldCfg.channels.filter(c => !_dynamicChannels.has(c.id)).map(c => [c.id, c]));
|
|
144
|
+
const newChannelMap = new Map(newCfg.channels.map(c => [c.id, c]));
|
|
145
|
+
for (const [id, newCh] of newChannelMap) {
|
|
146
|
+
const oldCh = oldChannelMap.get(id);
|
|
147
|
+
if (!oldCh) {
|
|
148
|
+
changes.push(`channel "${id}": added`);
|
|
149
|
+
}
|
|
150
|
+
else if (JSON.stringify(oldCh) !== JSON.stringify(newCh)) {
|
|
151
|
+
// Identify which fields changed
|
|
152
|
+
const changedFields = [];
|
|
153
|
+
for (const key of new Set([...Object.keys(oldCh), ...Object.keys(newCh)])) {
|
|
154
|
+
if (JSON.stringify(oldCh[key]) !== JSON.stringify(newCh[key])) {
|
|
155
|
+
changedFields.push(key);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
changes.push(`channel "${id}": ${changedFields.join(', ')} changed`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const id of oldChannelMap.keys()) {
|
|
162
|
+
if (!newChannelMap.has(id)) {
|
|
163
|
+
changes.push(`channel "${id}": removed from config (still active in-memory)`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// --- Platforms (check for restart-needed changes) ---
|
|
167
|
+
for (const [name, newPlat] of Object.entries(newCfg.platforms)) {
|
|
168
|
+
const oldPlat = oldCfg.platforms[name];
|
|
169
|
+
if (!oldPlat) {
|
|
170
|
+
restartNeeded.push(`platform "${name}": added (new adapter + WebSocket needed)`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (oldPlat.url !== newPlat.url) {
|
|
174
|
+
restartNeeded.push(`platform "${name}": URL changed (adapter caches URL at startup)`);
|
|
175
|
+
}
|
|
176
|
+
if (oldPlat.botToken !== newPlat.botToken) {
|
|
177
|
+
restartNeeded.push(`platform "${name}": botToken changed`);
|
|
178
|
+
}
|
|
179
|
+
// Check individual bots
|
|
180
|
+
const oldBots = oldPlat.bots ?? {};
|
|
181
|
+
const newBots = newPlat.bots ?? {};
|
|
182
|
+
for (const [bName, newBot] of Object.entries(newBots)) {
|
|
183
|
+
const oldBot = oldBots[bName];
|
|
184
|
+
if (!oldBot) {
|
|
185
|
+
restartNeeded.push(`bot "${name}:${bName}": added (new adapter needed)`);
|
|
186
|
+
}
|
|
187
|
+
else if (oldBot.token !== newBot.token) {
|
|
188
|
+
restartNeeded.push(`bot "${name}:${bName}": token changed`);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
// Non-token bot fields are hot-reloadable (agent, admin)
|
|
192
|
+
if (JSON.stringify(oldBot) !== JSON.stringify(newBot)) {
|
|
193
|
+
changes.push(`bot "${name}:${bName}": config updated`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const bName of Object.keys(oldBots)) {
|
|
198
|
+
if (!newBots[bName]) {
|
|
199
|
+
restartNeeded.push(`bot "${name}:${bName}": removed (adapter still running)`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const name of Object.keys(oldCfg.platforms)) {
|
|
204
|
+
if (!newCfg.platforms[name]) {
|
|
205
|
+
restartNeeded.push(`platform "${name}": removed (adapter still running)`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { changes, restartNeeded };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Re-read config from disk, validate, diff, and apply.
|
|
212
|
+
* On failure, keeps existing config and returns the error.
|
|
213
|
+
* Dynamic channels are preserved across reloads.
|
|
214
|
+
*/
|
|
215
|
+
export function reloadConfig() {
|
|
216
|
+
if (!_configPath) {
|
|
217
|
+
return { success: false, error: 'No config path — loadConfig() not called', changes: [], restartNeeded: [] };
|
|
218
|
+
}
|
|
219
|
+
if (!_config) {
|
|
220
|
+
return { success: false, error: 'No existing config to reload', changes: [], restartNeeded: [] };
|
|
221
|
+
}
|
|
222
|
+
let raw;
|
|
223
|
+
try {
|
|
224
|
+
const text = fs.readFileSync(_configPath, 'utf-8');
|
|
225
|
+
raw = JSON.parse(text);
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
return { success: false, error: `Failed to read config: ${err.message}`, changes: [], restartNeeded: [] };
|
|
229
|
+
}
|
|
230
|
+
let newConfig;
|
|
231
|
+
try {
|
|
232
|
+
newConfig = validateAndNormalize(raw);
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return { success: false, error: `Validation failed: ${err.message}`, changes: [], restartNeeded: [] };
|
|
236
|
+
}
|
|
237
|
+
const { changes, restartNeeded } = diffConfigs(_config, newConfig);
|
|
238
|
+
// Preserve channels that were removed from config but have no replacement
|
|
239
|
+
// (grace period: they stay in-memory until sessions end)
|
|
240
|
+
const removedStaticIds = [];
|
|
241
|
+
for (const oldCh of _config.channels) {
|
|
242
|
+
if (_dynamicChannels.has(oldCh.id))
|
|
243
|
+
continue; // dynamic, handled separately
|
|
244
|
+
if (!newConfig.channels.some(c => c.id === oldCh.id)) {
|
|
245
|
+
removedStaticIds.push(oldCh.id);
|
|
246
|
+
newConfig.channels.push(oldCh); // keep in-memory
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
// Merge dynamic channels back in (prune any now covered by static config)
|
|
250
|
+
for (const [id, ch] of _dynamicChannels) {
|
|
251
|
+
if (newConfig.channels.some(c => c.id === id)) {
|
|
252
|
+
_dynamicChannels.delete(id); // static config now covers this channel
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
newConfig.channels.push(ch);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
_config = newConfig;
|
|
259
|
+
return { success: true, changes, restartNeeded };
|
|
260
|
+
}
|
|
261
|
+
export function getConfig() {
|
|
262
|
+
if (!_config)
|
|
263
|
+
throw new Error('Config not loaded. Call loadConfig() first.');
|
|
264
|
+
return _config;
|
|
265
|
+
}
|
|
266
|
+
export function getInterAgentConfig() {
|
|
267
|
+
const config = getConfig();
|
|
268
|
+
return config.interAgent ?? { enabled: false };
|
|
269
|
+
}
|
|
270
|
+
export function getChannelConfig(channelId) {
|
|
271
|
+
const config = getConfig();
|
|
272
|
+
let channel = config.channels.find(c => c.id === channelId);
|
|
273
|
+
// Fall back to dynamic channels from SQLite
|
|
274
|
+
if (!channel) {
|
|
275
|
+
const dyn = getDynamicChannel(channelId);
|
|
276
|
+
if (dyn) {
|
|
277
|
+
channel = {
|
|
278
|
+
id: dyn.channelId,
|
|
279
|
+
platform: dyn.platform,
|
|
280
|
+
name: dyn.name,
|
|
281
|
+
workingDirectory: dyn.workingDirectory,
|
|
282
|
+
bot: dyn.bot,
|
|
283
|
+
agent: dyn.agent,
|
|
284
|
+
model: dyn.model,
|
|
285
|
+
triggerMode: dyn.triggerMode ?? config.defaults.triggerMode,
|
|
286
|
+
threadedReplies: dyn.threadedReplies ?? config.defaults.threadedReplies,
|
|
287
|
+
verbose: dyn.verbose ?? config.defaults.verbose,
|
|
288
|
+
isDM: dyn.isDM,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!channel)
|
|
293
|
+
throw new Error(`No config found for channel "${channelId}"`);
|
|
294
|
+
// Resolve agent: channel-level overrides bot-level default
|
|
295
|
+
const botConfig = getChannelBotConfig(channelId);
|
|
296
|
+
const resolvedAgent = channel.agent !== undefined ? channel.agent
|
|
297
|
+
: botConfig?.agent !== undefined ? botConfig.agent
|
|
298
|
+
: config.defaults.agent;
|
|
299
|
+
return {
|
|
300
|
+
...channel,
|
|
301
|
+
model: channel.model ?? config.defaults.model,
|
|
302
|
+
agent: resolvedAgent,
|
|
303
|
+
triggerMode: channel.triggerMode ?? config.defaults.triggerMode,
|
|
304
|
+
threadedReplies: channel.threadedReplies ?? config.defaults.threadedReplies,
|
|
305
|
+
verbose: channel.verbose ?? config.defaults.verbose,
|
|
306
|
+
permissionMode: config.defaults.permissionMode,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** Check if a channel ID is in our configured channels list (static or dynamic) */
|
|
310
|
+
export function isConfiguredChannel(channelId) {
|
|
311
|
+
const config = getConfig();
|
|
312
|
+
if (config.channels.some(c => c.id === channelId))
|
|
313
|
+
return true;
|
|
314
|
+
return getDynamicChannel(channelId) !== null;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Dynamically register a channel at runtime (not persisted to config.json).
|
|
318
|
+
* Used for auto-discovered DM channels with bots.
|
|
319
|
+
* Stored separately from static config so they survive reloads.
|
|
320
|
+
*/
|
|
321
|
+
export function registerDynamicChannel(channel) {
|
|
322
|
+
const config = getConfig();
|
|
323
|
+
if (config.channels.some(c => c.id === channel.id))
|
|
324
|
+
return; // already in static config
|
|
325
|
+
_dynamicChannels.set(channel.id, channel);
|
|
326
|
+
config.channels.push(channel);
|
|
327
|
+
}
|
|
328
|
+
/** Mark an existing channel as a DM (mutates the source config object and dynamic store). */
|
|
329
|
+
export function markChannelAsDM(channelId) {
|
|
330
|
+
const config = getConfig();
|
|
331
|
+
const channel = config.channels.find(c => c.id === channelId);
|
|
332
|
+
if (channel)
|
|
333
|
+
channel.isDM = true;
|
|
334
|
+
const dyn = _dynamicChannels.get(channelId);
|
|
335
|
+
if (dyn)
|
|
336
|
+
dyn.isDM = true;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get the resolved bot token for a channel.
|
|
340
|
+
* Supports both single-bot (botToken) and multi-bot (bots map) configs.
|
|
341
|
+
*/
|
|
342
|
+
export function getChannelBotToken(channelId) {
|
|
343
|
+
const config = getConfig();
|
|
344
|
+
let channel = config.channels.find(c => c.id === channelId);
|
|
345
|
+
// Fall back to dynamic channels
|
|
346
|
+
if (!channel) {
|
|
347
|
+
const dyn = getDynamicChannel(channelId);
|
|
348
|
+
if (dyn)
|
|
349
|
+
channel = { platform: dyn.platform, bot: dyn.bot };
|
|
350
|
+
}
|
|
351
|
+
if (!channel)
|
|
352
|
+
throw new Error(`No config found for channel "${channelId}"`);
|
|
353
|
+
const platform = config.platforms[channel.platform];
|
|
354
|
+
if (!platform)
|
|
355
|
+
throw new Error(`Channel "${channelId}" references unknown platform "${channel.platform}"`);
|
|
356
|
+
if (channel.bot && platform.bots?.[channel.bot]) {
|
|
357
|
+
return platform.bots[channel.bot].token;
|
|
358
|
+
}
|
|
359
|
+
if (platform.botToken)
|
|
360
|
+
return platform.botToken;
|
|
361
|
+
if (platform.bots) {
|
|
362
|
+
const first = Object.values(platform.bots)[0];
|
|
363
|
+
if (first)
|
|
364
|
+
return first.token;
|
|
365
|
+
}
|
|
366
|
+
throw new Error(`No bot token resolved for channel "${channelId}"`);
|
|
367
|
+
}
|
|
368
|
+
/** Get the BotConfig for a channel (if multi-bot). */
|
|
369
|
+
export function getChannelBotConfig(channelId) {
|
|
370
|
+
const config = getConfig();
|
|
371
|
+
let channel = config.channels.find(c => c.id === channelId);
|
|
372
|
+
if (!channel) {
|
|
373
|
+
const dyn = getDynamicChannel(channelId);
|
|
374
|
+
if (dyn)
|
|
375
|
+
channel = { platform: dyn.platform, bot: dyn.bot };
|
|
376
|
+
}
|
|
377
|
+
if (!channel)
|
|
378
|
+
return null;
|
|
379
|
+
const platform = config.platforms[channel.platform];
|
|
380
|
+
if (!platform)
|
|
381
|
+
return null;
|
|
382
|
+
if (channel.bot && platform.bots?.[channel.bot]) {
|
|
383
|
+
return platform.bots[channel.bot];
|
|
384
|
+
}
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Get all unique bot tokens for a platform, keyed by bot name.
|
|
389
|
+
* For single-bot configs, returns { "default": token }.
|
|
390
|
+
*/
|
|
391
|
+
export function getPlatformBots(platformName) {
|
|
392
|
+
const config = getConfig();
|
|
393
|
+
const platform = config.platforms[platformName];
|
|
394
|
+
if (!platform)
|
|
395
|
+
throw new Error(`Unknown platform "${platformName}"`);
|
|
396
|
+
const bots = new Map();
|
|
397
|
+
if (platform.bots) {
|
|
398
|
+
for (const [name, bot] of Object.entries(platform.bots)) {
|
|
399
|
+
bots.set(name, { token: bot.token, agent: bot.agent });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
else if (platform.botToken) {
|
|
403
|
+
bots.set('default', { token: platform.botToken });
|
|
404
|
+
}
|
|
405
|
+
return bots;
|
|
406
|
+
}
|
|
407
|
+
/** Check if a bot is an admin. */
|
|
408
|
+
export function isBotAdmin(platformName, botName) {
|
|
409
|
+
const config = getConfig();
|
|
410
|
+
const platform = config.platforms[platformName];
|
|
411
|
+
if (!platform?.bots)
|
|
412
|
+
return false;
|
|
413
|
+
return !!platform.bots[botName]?.admin;
|
|
414
|
+
}
|
|
415
|
+
/** Check if a bot name is admin on any platform. */
|
|
416
|
+
export function isBotAdminAny(botName) {
|
|
417
|
+
const config = getConfig();
|
|
418
|
+
for (const platform of Object.values(config.platforms)) {
|
|
419
|
+
if (platform.bots && platform.bots[botName]?.admin)
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
/** Get the admin bot name for a platform, if any. */
|
|
425
|
+
export function getAdminBotName(platformName) {
|
|
426
|
+
const config = getConfig();
|
|
427
|
+
const platform = config.platforms[platformName];
|
|
428
|
+
if (!platform?.bots)
|
|
429
|
+
return null;
|
|
430
|
+
for (const [name, bot] of Object.entries(platform.bots)) {
|
|
431
|
+
if (bot.admin)
|
|
432
|
+
return name;
|
|
433
|
+
}
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
/** Get the bot name a channel uses. */
|
|
437
|
+
export function getChannelBotName(channelId) {
|
|
438
|
+
const config = getConfig();
|
|
439
|
+
let channel = config.channels.find(c => c.id === channelId);
|
|
440
|
+
if (!channel) {
|
|
441
|
+
const dyn = getDynamicChannel(channelId);
|
|
442
|
+
if (dyn)
|
|
443
|
+
channel = { platform: dyn.platform, bot: dyn.bot };
|
|
444
|
+
}
|
|
445
|
+
if (!channel)
|
|
446
|
+
return 'default';
|
|
447
|
+
if (channel.bot)
|
|
448
|
+
return channel.bot;
|
|
449
|
+
const platform = config.platforms[channel.platform];
|
|
450
|
+
if (platform?.bots)
|
|
451
|
+
return Object.keys(platform.bots)[0] ?? 'default';
|
|
452
|
+
return 'default';
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Parse a CLI-compatible permission spec.
|
|
456
|
+
* Examples: "shell(ls)", "shell(git status)", "shell", "write", "read",
|
|
457
|
+
* "workiq(calendar_list)", "workiq", "url(github.com)"
|
|
458
|
+
* Returns { kind, tool? } where tool is the parenthesized part.
|
|
459
|
+
*/
|
|
460
|
+
function parsePermissionSpec(spec) {
|
|
461
|
+
const match = spec.match(/^([^(]+?)(?:\((.+)\))?$/);
|
|
462
|
+
if (!match)
|
|
463
|
+
return { kind: spec };
|
|
464
|
+
return { kind: match[1].trim(), tool: match[2]?.trim() };
|
|
465
|
+
}
|
|
466
|
+
const SHELL_WRAPPERS = new Set(['bash', 'sh', 'zsh', 'dash', 'fish', 'env', 'sudo', 'nohup', 'xargs', 'exec', 'eval']);
|
|
467
|
+
/** Strip shell wrappers, absolute paths, and subshell flags to find the real command. */
|
|
468
|
+
function unwrapShellCommand(cmd) {
|
|
469
|
+
// Handle bash/sh -c "..." — extract the quoted payload (with optional sudo/env prefix)
|
|
470
|
+
const dashCMatch = cmd.match(/(?:^|\s)(?:(?:sudo|env)\s+)*(?:bash|sh|zsh|dash)\s+-c\s+["'](.+?)["']\s*$/);
|
|
471
|
+
if (dashCMatch) {
|
|
472
|
+
return unwrapShellCommand(dashCMatch[1]);
|
|
473
|
+
}
|
|
474
|
+
let parts = cmd.trim().split(/\s+/);
|
|
475
|
+
// Strip wrappers from front (sudo rm -rf / → rm -rf /)
|
|
476
|
+
while (parts.length > 0) {
|
|
477
|
+
let word = parts[0];
|
|
478
|
+
// Strip absolute path prefix: /usr/bin/rm → rm
|
|
479
|
+
const base = word.includes('/') ? word.split('/').pop() : word;
|
|
480
|
+
if (SHELL_WRAPPERS.has(base)) {
|
|
481
|
+
parts.shift();
|
|
482
|
+
if (base === 'env') {
|
|
483
|
+
// Skip env assignments (FOO=bar) and flags
|
|
484
|
+
while (parts.length > 0 && (parts[0].startsWith('-') || /^[A-Z_]+=/.test(parts[0])))
|
|
485
|
+
parts.shift();
|
|
486
|
+
}
|
|
487
|
+
else if (base === 'sudo') {
|
|
488
|
+
// Skip sudo flags and their arguments (-u root, -g group, -C fd, etc.)
|
|
489
|
+
const sudoFlagsWithArg = new Set(['-u', '-g', '-C', '-D', '-R', '-T', '--user', '--group']);
|
|
490
|
+
while (parts.length > 0 && parts[0].startsWith('-')) {
|
|
491
|
+
const flag = parts.shift();
|
|
492
|
+
if (sudoFlagsWithArg.has(flag) && parts.length > 0)
|
|
493
|
+
parts.shift(); // skip arg
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
// Generic wrapper: skip flags
|
|
498
|
+
while (parts.length > 0 && parts[0].startsWith('-'))
|
|
499
|
+
parts.shift();
|
|
500
|
+
}
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
// Rewrite absolute path to basename for the first real command
|
|
504
|
+
if (word.includes('/')) {
|
|
505
|
+
parts[0] = base;
|
|
506
|
+
}
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
return parts.join(' ');
|
|
510
|
+
}
|
|
511
|
+
const HARDCODED_DENY_RULES = [
|
|
512
|
+
{
|
|
513
|
+
spec: 'shell(launchctl unload)',
|
|
514
|
+
test: (eff, shellCmd, orig) => (shellCmd === 'launchctl' || orig.trim().split(/\s+/)[0] === 'launchctl') && /\bunload\b/.test(orig),
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
spec: 'shell(rm -rf /)',
|
|
518
|
+
test: (eff, shellCmd) => {
|
|
519
|
+
if (shellCmd !== 'rm')
|
|
520
|
+
return false;
|
|
521
|
+
const hasRecursive = /\s-[^\s]*r|\s--recursive/.test(eff);
|
|
522
|
+
const hasForce = /\s-[^\s]*f|\s--force/.test(eff);
|
|
523
|
+
return hasRecursive && hasForce && (/\s+\/(\s|$)/.test(eff) || /\s+\/\*(\s|$)/.test(eff));
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
{
|
|
527
|
+
spec: 'shell(rm -rf ~)',
|
|
528
|
+
test: (eff, shellCmd) => {
|
|
529
|
+
if (shellCmd !== 'rm')
|
|
530
|
+
return false;
|
|
531
|
+
const hasRecursive = /\s-[^\s]*r|\s--recursive/.test(eff);
|
|
532
|
+
const hasForce = /\s-[^\s]*f|\s--force/.test(eff);
|
|
533
|
+
return hasRecursive && hasForce && (/\s+~(\s|$)/.test(eff) || /\$HOME(\s|$)/.test(eff));
|
|
534
|
+
},
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
spec: 'shell(mkfs)',
|
|
538
|
+
test: (_eff, shellCmd) => shellCmd === 'mkfs' || /^mkfs\./.test(shellCmd),
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
spec: 'shell(dd … of=/dev/*)',
|
|
542
|
+
test: (eff, shellCmd) => shellCmd === 'dd' && /of=\/dev\//.test(eff),
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
spec: 'shell(:(){ :|:& };:)',
|
|
546
|
+
test: (_eff, _shellCmd, orig) => /:\(\)\s*\{.*:\|:.*&.*\}\s*;?\s*:/.test(orig),
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
spec: 'shell(chmod -R / /etc /usr /var ~)',
|
|
550
|
+
test: (eff, shellCmd) => shellCmd === 'chmod' && /\s-[^\s]*R/.test(eff) &&
|
|
551
|
+
(/\s+\/(\s|$)/.test(eff) || /\s+\/etc(\s|\/|$)/.test(eff) ||
|
|
552
|
+
/\s+\/usr(\s|\/|$)/.test(eff) || /\s+\/var(\s|\/|$)/.test(eff) ||
|
|
553
|
+
/\s+~(\s|$)/.test(eff) || /\$HOME(\s|$)/.test(eff)),
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
spec: 'shell(chown -R / /etc /usr /var ~)',
|
|
557
|
+
test: (eff, shellCmd) => shellCmd === 'chown' && /\s-[^\s]*R/.test(eff) &&
|
|
558
|
+
(/\s+\/(\s|$)/.test(eff) || /\s+\/etc(\s|\/|$)/.test(eff) ||
|
|
559
|
+
/\s+\/usr(\s|\/|$)/.test(eff) || /\s+\/var(\s|\/|$)/.test(eff) ||
|
|
560
|
+
/\s+~(\s|$)/.test(eff) || /\$HOME(\s|$)/.test(eff)),
|
|
561
|
+
},
|
|
562
|
+
];
|
|
563
|
+
/**
|
|
564
|
+
* Hardcoded safety denies — cannot be overridden by config or stored rules.
|
|
565
|
+
* These prevent destructive commands that should never run in any context.
|
|
566
|
+
*/
|
|
567
|
+
export function isHardDeny(kind, command) {
|
|
568
|
+
if (kind !== 'shell' || !command)
|
|
569
|
+
return false;
|
|
570
|
+
const cmd = command.trim();
|
|
571
|
+
const unwrapped = unwrapShellCommand(cmd);
|
|
572
|
+
const realCmd = unwrapped.split(/\s+/)[0];
|
|
573
|
+
return HARDCODED_DENY_RULES.some(rule => rule.test(unwrapped, realCmd, cmd));
|
|
574
|
+
}
|
|
575
|
+
/** Built-in safety rules surfaced by /remember list — derived from HARDCODED_DENY_RULES. */
|
|
576
|
+
export function getHardcodedRules() {
|
|
577
|
+
return HARDCODED_DENY_RULES.map(rule => ({ spec: rule.spec, action: 'deny', source: 'hardcoded' }));
|
|
578
|
+
}
|
|
579
|
+
/** Config-level rules surfaced by /remember list. */
|
|
580
|
+
export function getConfigRules() {
|
|
581
|
+
const config = getConfig();
|
|
582
|
+
const perms = config.permissions;
|
|
583
|
+
const rules = [];
|
|
584
|
+
if (perms?.deny) {
|
|
585
|
+
for (const spec of perms.deny)
|
|
586
|
+
rules.push({ spec, action: 'deny', source: 'config' });
|
|
587
|
+
}
|
|
588
|
+
if (perms?.allow) {
|
|
589
|
+
for (const spec of perms.allow)
|
|
590
|
+
rules.push({ spec, action: 'allow', source: 'config' });
|
|
591
|
+
}
|
|
592
|
+
if (perms?.allowPaths) {
|
|
593
|
+
for (const p of perms.allowPaths)
|
|
594
|
+
rules.push({ spec: `path: ${p}`, action: 'allow', source: 'config' });
|
|
595
|
+
}
|
|
596
|
+
if (perms?.allowUrls) {
|
|
597
|
+
for (const u of perms.allowUrls)
|
|
598
|
+
rules.push({ spec: `url: ${u}`, action: 'allow', source: 'config' });
|
|
599
|
+
}
|
|
600
|
+
return rules;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Evaluate config-level permission rules against a permission request.
|
|
604
|
+
* Uses CLI-compatible syntax: shell(cmd), write, read, MCP_SERVER(tool), etc.
|
|
605
|
+
*
|
|
606
|
+
* @returns 'allow' | 'deny' | null (null = no rule matched, ask user)
|
|
607
|
+
*/
|
|
608
|
+
export function evaluateConfigPermissions(request, channelWorkingDirectory, workspaceAllowPaths, isAdmin) {
|
|
609
|
+
const config = getConfig();
|
|
610
|
+
const perms = config.permissions;
|
|
611
|
+
const kind = request.kind;
|
|
612
|
+
const command = typeof request.fullCommandText === 'string' ? request.fullCommandText
|
|
613
|
+
: typeof request.command === 'string' ? request.command : undefined;
|
|
614
|
+
const requestPath = typeof request.path === 'string' ? request.path
|
|
615
|
+
: typeof request.fileName === 'string' ? request.fileName : undefined;
|
|
616
|
+
const serverName = typeof request.serverName === 'string' ? request.serverName : undefined;
|
|
617
|
+
const toolName = typeof request.toolName === 'string' ? request.toolName : undefined;
|
|
618
|
+
const url = typeof request.url === 'string' ? request.url : undefined;
|
|
619
|
+
const shellCmd = command ? command.trim().split(/\s+/)[0] : undefined;
|
|
620
|
+
const shellCmdFull = command ? (() => {
|
|
621
|
+
const parts = command.trim().split(/\s+/);
|
|
622
|
+
if ((parts[0] === 'git' || parts[0] === 'gh') && parts.length > 1) {
|
|
623
|
+
return `${parts[0]} ${parts[1]}`;
|
|
624
|
+
}
|
|
625
|
+
return parts[0];
|
|
626
|
+
})() : undefined;
|
|
627
|
+
// Hardcoded safety denies — cannot be overridden
|
|
628
|
+
if (isHardDeny(kind, command)) {
|
|
629
|
+
return 'deny';
|
|
630
|
+
}
|
|
631
|
+
// Check config deny rules (deny takes precedence over allow)
|
|
632
|
+
if (perms?.deny) {
|
|
633
|
+
for (const spec of perms.deny) {
|
|
634
|
+
const parsed = parsePermissionSpec(spec);
|
|
635
|
+
if (matchesRule(parsed, kind, shellCmd, shellCmdFull, command, serverName, toolName)) {
|
|
636
|
+
return 'deny';
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// Check config allow rules
|
|
641
|
+
if (perms?.allow) {
|
|
642
|
+
for (const spec of perms.allow) {
|
|
643
|
+
const parsed = parsePermissionSpec(spec);
|
|
644
|
+
if (matchesRule(parsed, kind, shellCmd, shellCmdFull, command, serverName, toolName)) {
|
|
645
|
+
return 'allow';
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// Admin bots get additional shell commands for workspace/config management
|
|
650
|
+
if (isAdmin && kind === 'shell' && shellCmd) {
|
|
651
|
+
const adminShellAllow = ['cp', 'mkdir', 'curl', 'launchctl', 'mv', 'touch', 'chmod'];
|
|
652
|
+
if (adminShellAllow.includes(shellCmd)) {
|
|
653
|
+
return 'allow';
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Auto-allow reads and writes within the workspace directory
|
|
657
|
+
if ((kind === 'read' || kind === 'write') && requestPath) {
|
|
658
|
+
const resolved = path.resolve(requestPath);
|
|
659
|
+
const workspace = path.resolve(channelWorkingDirectory);
|
|
660
|
+
if (resolved.startsWith(workspace + path.sep) || resolved === workspace) {
|
|
661
|
+
return 'allow';
|
|
662
|
+
}
|
|
663
|
+
// Check workspace-level allowPaths (from SQLite override)
|
|
664
|
+
if (workspaceAllowPaths) {
|
|
665
|
+
for (const p of workspaceAllowPaths) {
|
|
666
|
+
const allowed = path.resolve(p);
|
|
667
|
+
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
|
|
668
|
+
return 'allow';
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Check config-level allowPaths
|
|
673
|
+
if (perms?.allowPaths) {
|
|
674
|
+
for (const p of perms.allowPaths) {
|
|
675
|
+
const allowed = path.resolve(p);
|
|
676
|
+
if (resolved.startsWith(allowed + path.sep) || resolved === allowed) {
|
|
677
|
+
return 'allow';
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// If a read/write has a path that wasn't auto-allowed above, it's outside
|
|
683
|
+
// the workspace boundaries — defer to the interactive approval flow so the
|
|
684
|
+
// user can still approve one-off access via the messaging channel.
|
|
685
|
+
if ((kind === 'read' || kind === 'write') && requestPath) {
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
// Check URL permissions
|
|
689
|
+
if (kind === 'url' && url && perms?.allowUrls) {
|
|
690
|
+
try {
|
|
691
|
+
const hostname = new URL(url).hostname;
|
|
692
|
+
if (perms.allowUrls.some(d => hostname === d || hostname.endsWith('.' + d))) {
|
|
693
|
+
return 'allow';
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch { /* invalid URL, don't auto-allow */ }
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
function matchesRule(parsed, requestKind, shellCmd, shellCmdFull, commandText, serverName, toolName) {
|
|
701
|
+
// Direct kind match: "shell", "read", "write"
|
|
702
|
+
if (parsed.kind === requestKind) {
|
|
703
|
+
if (!parsed.tool)
|
|
704
|
+
return true; // bare kind matches all of that kind
|
|
705
|
+
// For shell: match command name, subcommand, or command prefix
|
|
706
|
+
if (requestKind === 'shell') {
|
|
707
|
+
if (parsed.tool === shellCmd || parsed.tool === shellCmdFull)
|
|
708
|
+
return true;
|
|
709
|
+
// Prefix match: "open -a Obsidian" matches command "open -a Obsidian --vault foo"
|
|
710
|
+
if (commandText) {
|
|
711
|
+
const trimmed = commandText.trim();
|
|
712
|
+
if (trimmed === parsed.tool || trimmed.startsWith(parsed.tool + ' '))
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
// MCP server match: spec like "workiq" or "workiq(calendar_list)"
|
|
720
|
+
if (requestKind === 'mcp' && serverName) {
|
|
721
|
+
if (parsed.kind === serverName) {
|
|
722
|
+
if (!parsed.tool)
|
|
723
|
+
return true; // bare server name matches all tools
|
|
724
|
+
return parsed.tool === toolName;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Watches config.json for changes and triggers hot-reload.
|
|
731
|
+
* Follows the same pattern as WorkspaceWatcher: fs.watch + debounce.
|
|
732
|
+
*/
|
|
733
|
+
export class ConfigWatcher {
|
|
734
|
+
watcher = null;
|
|
735
|
+
handlers = [];
|
|
736
|
+
debounceTimer = null;
|
|
737
|
+
debounceMs;
|
|
738
|
+
constructor(debounceMs = 500) {
|
|
739
|
+
this.debounceMs = debounceMs;
|
|
740
|
+
}
|
|
741
|
+
/** Start watching the config file. */
|
|
742
|
+
start() {
|
|
743
|
+
const configPath = getConfigPath();
|
|
744
|
+
if (!configPath) {
|
|
745
|
+
log.warn('Cannot start ConfigWatcher: no config path (loadConfig not called)');
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (this.watcher)
|
|
749
|
+
return;
|
|
750
|
+
// Watch the parent directory (not the file) because editors do atomic saves
|
|
751
|
+
// (write temp + rename), which replaces the inode and kills file-level watchers.
|
|
752
|
+
const dir = path.dirname(configPath);
|
|
753
|
+
const filename = path.basename(configPath);
|
|
754
|
+
log.info(`Watching ${dir} for changes to ${filename}`);
|
|
755
|
+
this.watcher = fs.watch(dir, { persistent: false }, (_event, changedFile) => {
|
|
756
|
+
if (!changedFile || String(changedFile) !== filename)
|
|
757
|
+
return;
|
|
758
|
+
if (this.debounceTimer)
|
|
759
|
+
clearTimeout(this.debounceTimer);
|
|
760
|
+
this.debounceTimer = setTimeout(() => this.handleChange(), this.debounceMs);
|
|
761
|
+
});
|
|
762
|
+
this.watcher.on('error', (err) => {
|
|
763
|
+
log.error('ConfigWatcher error:', err);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
/** Stop watching. */
|
|
767
|
+
stop() {
|
|
768
|
+
if (this.watcher) {
|
|
769
|
+
this.watcher.close();
|
|
770
|
+
this.watcher = null;
|
|
771
|
+
}
|
|
772
|
+
if (this.debounceTimer) {
|
|
773
|
+
clearTimeout(this.debounceTimer);
|
|
774
|
+
this.debounceTimer = null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/** Register a reload event handler. */
|
|
778
|
+
onReload(handler) {
|
|
779
|
+
this.handlers.push(handler);
|
|
780
|
+
}
|
|
781
|
+
handleChange() {
|
|
782
|
+
const result = reloadConfig();
|
|
783
|
+
if (result.success) {
|
|
784
|
+
if (result.changes.length || result.restartNeeded.length) {
|
|
785
|
+
log.info(`Config reloaded: ${result.changes.length} change(s), ${result.restartNeeded.length} restart-needed`);
|
|
786
|
+
for (const c of result.changes)
|
|
787
|
+
log.info(` ✓ ${c}`);
|
|
788
|
+
for (const r of result.restartNeeded)
|
|
789
|
+
log.warn(` ⚠ ${r}`);
|
|
790
|
+
}
|
|
791
|
+
else {
|
|
792
|
+
log.debug('Config file changed but no effective differences');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
log.error(`Config reload failed: ${result.error} — keeping existing config`);
|
|
797
|
+
}
|
|
798
|
+
for (const handler of this.handlers) {
|
|
799
|
+
try {
|
|
800
|
+
handler(result);
|
|
801
|
+
}
|
|
802
|
+
catch (err) {
|
|
803
|
+
log.error('Reload handler error:', err);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Reset config state — for testing only.
|
|
810
|
+
* @internal
|
|
811
|
+
*/
|
|
812
|
+
export function _resetConfigForTest() {
|
|
813
|
+
_config = null;
|
|
814
|
+
_configPath = null;
|
|
815
|
+
_dynamicChannels.clear();
|
|
816
|
+
}
|
|
817
|
+
//# sourceMappingURL=config.js.map
|