@mauribadnights/clooks 0.2.0 → 0.3.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/README.md +133 -5
- package/dist/auth.d.ts +17 -0
- package/dist/auth.js +109 -0
- package/dist/builtin-hooks.d.ts +11 -0
- package/dist/builtin-hooks.js +67 -0
- package/dist/cli.js +136 -5
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +5 -1
- package/dist/deps.d.ts +13 -0
- package/dist/deps.js +83 -0
- package/dist/doctor.js +110 -0
- package/dist/handlers.d.ts +11 -3
- package/dist/handlers.js +99 -46
- package/dist/index.d.ts +11 -4
- package/dist/index.js +30 -1
- package/dist/llm.d.ts +1 -1
- package/dist/llm.js +8 -4
- package/dist/manifest.d.ts +5 -1
- package/dist/manifest.js +33 -5
- package/dist/metrics.d.ts +8 -1
- package/dist/metrics.js +32 -6
- package/dist/migrate.js +21 -2
- package/dist/plugin.d.ts +50 -0
- package/dist/plugin.js +279 -0
- package/dist/ratelimit.d.ts +12 -0
- package/dist/ratelimit.js +44 -0
- package/dist/server.d.ts +13 -2
- package/dist/server.js +168 -11
- package/dist/shortcircuit.d.ts +20 -0
- package/dist/shortcircuit.js +49 -0
- package/dist/types.d.ts +36 -0
- package/dist/watcher.d.ts +18 -0
- package/dist/watcher.js +120 -0
- package/hooks/check-update.js +37 -0
- package/package.json +2 -1
package/dist/metrics.js
CHANGED
|
@@ -6,15 +6,43 @@ const fs_1 = require("fs");
|
|
|
6
6
|
const path_1 = require("path");
|
|
7
7
|
const constants_js_1 = require("./constants.js");
|
|
8
8
|
class MetricsCollector {
|
|
9
|
+
static MAX_ENTRIES = 1000;
|
|
9
10
|
entries = [];
|
|
10
|
-
|
|
11
|
+
ringIndex = 0;
|
|
12
|
+
totalRecorded = 0;
|
|
13
|
+
static METRICS_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
|
14
|
+
static COSTS_MAX_BYTES = 1 * 1024 * 1024; // 1MB
|
|
15
|
+
/** Rotate a log file if it exceeds maxBytes. Keeps one backup (.1). */
|
|
16
|
+
rotateIfNeeded(filePath, maxBytes) {
|
|
17
|
+
try {
|
|
18
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
19
|
+
return;
|
|
20
|
+
const stat = (0, fs_1.statSync)(filePath);
|
|
21
|
+
if (stat.size >= maxBytes) {
|
|
22
|
+
(0, fs_1.renameSync)(filePath, filePath + '.1');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Non-critical — rotation failure is not fatal
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Record a metric entry in memory (ring buffer) and append to disk. */
|
|
11
30
|
record(entry) {
|
|
12
|
-
|
|
31
|
+
// Ring buffer: overwrite oldest when full
|
|
32
|
+
if (this.entries.length < MetricsCollector.MAX_ENTRIES) {
|
|
33
|
+
this.entries.push(entry);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
this.entries[this.ringIndex] = entry;
|
|
37
|
+
this.ringIndex = (this.ringIndex + 1) % MetricsCollector.MAX_ENTRIES;
|
|
38
|
+
}
|
|
39
|
+
this.totalRecorded++;
|
|
13
40
|
try {
|
|
14
41
|
const dir = (0, path_1.dirname)(constants_js_1.METRICS_FILE);
|
|
15
42
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
16
43
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
17
44
|
}
|
|
45
|
+
this.rotateIfNeeded(constants_js_1.METRICS_FILE, MetricsCollector.METRICS_MAX_BYTES);
|
|
18
46
|
(0, fs_1.appendFileSync)(constants_js_1.METRICS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
19
47
|
}
|
|
20
48
|
catch {
|
|
@@ -46,10 +74,7 @@ class MetricsCollector {
|
|
|
46
74
|
}
|
|
47
75
|
/** Get stats for a specific session. */
|
|
48
76
|
getSessionStats(sessionId) {
|
|
49
|
-
const all = this.loadAll().filter((e) =>
|
|
50
|
-
// MetricEntry doesn't have session_id, but we stored it in the entry if available
|
|
51
|
-
return e.session_id === sessionId;
|
|
52
|
-
});
|
|
77
|
+
const all = this.loadAll().filter((e) => e.session_id === sessionId);
|
|
53
78
|
const byEvent = new Map();
|
|
54
79
|
for (const entry of all) {
|
|
55
80
|
const existing = byEvent.get(entry.event) ?? [];
|
|
@@ -108,6 +133,7 @@ class MetricsCollector {
|
|
|
108
133
|
if (!(0, fs_1.existsSync)(dir)) {
|
|
109
134
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
110
135
|
}
|
|
136
|
+
this.rotateIfNeeded(constants_js_1.COSTS_FILE, MetricsCollector.COSTS_MAX_BYTES);
|
|
111
137
|
(0, fs_1.appendFileSync)(constants_js_1.COSTS_FILE, JSON.stringify(entry) + '\n', 'utf-8');
|
|
112
138
|
}
|
|
113
139
|
catch {
|
package/dist/migrate.js
CHANGED
|
@@ -8,6 +8,7 @@ const fs_1 = require("fs");
|
|
|
8
8
|
const path_1 = require("path");
|
|
9
9
|
const os_1 = require("os");
|
|
10
10
|
const constants_js_1 = require("./constants.js");
|
|
11
|
+
const builtin_hooks_js_1 = require("./builtin-hooks.js");
|
|
11
12
|
const yaml_1 = require("yaml");
|
|
12
13
|
/**
|
|
13
14
|
* Find the Claude Code settings.json path.
|
|
@@ -91,6 +92,20 @@ function migrate(options) {
|
|
|
91
92
|
};
|
|
92
93
|
});
|
|
93
94
|
}
|
|
95
|
+
// Install built-in hook scripts
|
|
96
|
+
(0, builtin_hooks_js_1.installBuiltinHooks)();
|
|
97
|
+
// Add update checker to SessionStart handlers
|
|
98
|
+
const checkUpdatePath = (0, path_1.join)(constants_js_1.HOOKS_DIR, 'check-update.js');
|
|
99
|
+
if (!manifestHandlers['SessionStart']) {
|
|
100
|
+
manifestHandlers['SessionStart'] = [];
|
|
101
|
+
}
|
|
102
|
+
manifestHandlers['SessionStart'].unshift({
|
|
103
|
+
id: 'clooks-check-update',
|
|
104
|
+
type: 'script',
|
|
105
|
+
command: `node ${checkUpdatePath}`,
|
|
106
|
+
timeout: 6000,
|
|
107
|
+
enabled: true,
|
|
108
|
+
});
|
|
94
109
|
// Write manifest.yaml
|
|
95
110
|
const manifest = {
|
|
96
111
|
handlers: manifestHandlers,
|
|
@@ -129,10 +144,14 @@ function migrate(options) {
|
|
|
129
144
|
}
|
|
130
145
|
// Add HTTP hook
|
|
131
146
|
if (hadHandlers > 0) {
|
|
132
|
-
|
|
147
|
+
const httpHook = {
|
|
133
148
|
type: 'http',
|
|
134
149
|
url: `http://localhost:${constants_js_1.DEFAULT_PORT}/hooks/${eventName}`,
|
|
135
|
-
}
|
|
150
|
+
};
|
|
151
|
+
if (manifest.settings?.authToken) {
|
|
152
|
+
httpHook.headers = { Authorization: `Bearer ${manifest.settings.authToken}` };
|
|
153
|
+
}
|
|
154
|
+
hookEntries.push(httpHook);
|
|
136
155
|
}
|
|
137
156
|
if (hookEntries.length > 0) {
|
|
138
157
|
// Wrap in a single rule group (no matcher — clooks handles dispatch)
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { PluginManifest, PluginRegistry, InstalledPlugin, Manifest } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Load the plugin registry (installed.json).
|
|
4
|
+
*/
|
|
5
|
+
export declare function loadRegistry(registryPath?: string): PluginRegistry;
|
|
6
|
+
/**
|
|
7
|
+
* Save the plugin registry.
|
|
8
|
+
*/
|
|
9
|
+
export declare function saveRegistry(registry: PluginRegistry, registryPath?: string): void;
|
|
10
|
+
/**
|
|
11
|
+
* Validate a plugin manifest.
|
|
12
|
+
* Similar to validateManifest but checks plugin-specific fields (name, version required).
|
|
13
|
+
*/
|
|
14
|
+
export declare function validatePluginManifest(manifest: PluginManifest): void;
|
|
15
|
+
/**
|
|
16
|
+
* Load all installed plugins and return their manifests.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadPlugins(pluginsDir?: string, registryPath?: string): {
|
|
19
|
+
name: string;
|
|
20
|
+
manifest: PluginManifest;
|
|
21
|
+
}[];
|
|
22
|
+
/**
|
|
23
|
+
* Merge user manifest + plugin manifests into a composite manifest.
|
|
24
|
+
* Plugin handler IDs are namespaced as "pluginName/handlerId".
|
|
25
|
+
* Prefetch keys are unioned.
|
|
26
|
+
* Settings come from user manifest only.
|
|
27
|
+
*/
|
|
28
|
+
export declare function mergeManifests(userManifest: Manifest, plugins: {
|
|
29
|
+
name: string;
|
|
30
|
+
manifest: PluginManifest;
|
|
31
|
+
}[]): Manifest;
|
|
32
|
+
/**
|
|
33
|
+
* Install a plugin from a local directory path.
|
|
34
|
+
* 1. Read clooks-plugin.yaml from the path
|
|
35
|
+
* 2. Validate it
|
|
36
|
+
* 3. Copy the directory to plugins dir under {name}/
|
|
37
|
+
* 4. Register in installed.json
|
|
38
|
+
* 5. Resolve $PLUGIN_DIR in handler commands to the installed path
|
|
39
|
+
*/
|
|
40
|
+
export declare function installPlugin(sourcePath: string, pluginsDir?: string, registryPath?: string): InstalledPlugin;
|
|
41
|
+
/**
|
|
42
|
+
* Uninstall a plugin by name.
|
|
43
|
+
* 1. Remove from installed.json
|
|
44
|
+
* 2. Delete the plugin directory
|
|
45
|
+
*/
|
|
46
|
+
export declare function uninstallPlugin(name: string, pluginsDir?: string, registryPath?: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* List installed plugins.
|
|
49
|
+
*/
|
|
50
|
+
export declare function listPlugins(registryPath?: string): InstalledPlugin[];
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks plugin system — load, install, uninstall, merge plugin manifests
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.loadRegistry = loadRegistry;
|
|
5
|
+
exports.saveRegistry = saveRegistry;
|
|
6
|
+
exports.validatePluginManifest = validatePluginManifest;
|
|
7
|
+
exports.loadPlugins = loadPlugins;
|
|
8
|
+
exports.mergeManifests = mergeManifests;
|
|
9
|
+
exports.installPlugin = installPlugin;
|
|
10
|
+
exports.uninstallPlugin = uninstallPlugin;
|
|
11
|
+
exports.listPlugins = listPlugins;
|
|
12
|
+
const fs_1 = require("fs");
|
|
13
|
+
const path_1 = require("path");
|
|
14
|
+
const yaml_1 = require("yaml");
|
|
15
|
+
const constants_js_1 = require("./constants.js");
|
|
16
|
+
/**
|
|
17
|
+
* Load the plugin registry (installed.json).
|
|
18
|
+
*/
|
|
19
|
+
function loadRegistry(registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
20
|
+
if (!(0, fs_1.existsSync)(registryPath)) {
|
|
21
|
+
return { plugins: [] };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const raw = (0, fs_1.readFileSync)(registryPath, 'utf-8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (!parsed || !Array.isArray(parsed.plugins)) {
|
|
27
|
+
return { plugins: [] };
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { plugins: [] };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Save the plugin registry.
|
|
37
|
+
*/
|
|
38
|
+
function saveRegistry(registry, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
39
|
+
const dir = (0, path_1.resolve)(registryPath, '..');
|
|
40
|
+
if (!(0, fs_1.existsSync)(dir)) {
|
|
41
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
(0, fs_1.writeFileSync)(registryPath, JSON.stringify(registry, null, 2) + '\n', 'utf-8');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validate a plugin manifest.
|
|
47
|
+
* Similar to validateManifest but checks plugin-specific fields (name, version required).
|
|
48
|
+
*/
|
|
49
|
+
function validatePluginManifest(manifest) {
|
|
50
|
+
if (!manifest.name || typeof manifest.name !== 'string') {
|
|
51
|
+
throw new Error('Plugin manifest must have a "name" string field');
|
|
52
|
+
}
|
|
53
|
+
if (!manifest.version || typeof manifest.version !== 'string') {
|
|
54
|
+
throw new Error('Plugin manifest must have a "version" string field');
|
|
55
|
+
}
|
|
56
|
+
if (!manifest.handlers || typeof manifest.handlers !== 'object') {
|
|
57
|
+
throw new Error('Plugin manifest must have a "handlers" object');
|
|
58
|
+
}
|
|
59
|
+
const seenIds = new Set();
|
|
60
|
+
for (const [eventName, handlers] of Object.entries(manifest.handlers)) {
|
|
61
|
+
if (!constants_js_1.HOOK_EVENTS.includes(eventName)) {
|
|
62
|
+
throw new Error(`Unknown hook event: "${eventName}". Valid events: ${constants_js_1.HOOK_EVENTS.join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(handlers)) {
|
|
65
|
+
throw new Error(`Handlers for "${eventName}" must be an array`);
|
|
66
|
+
}
|
|
67
|
+
for (const handler of handlers) {
|
|
68
|
+
if (!handler.id || typeof handler.id !== 'string') {
|
|
69
|
+
throw new Error(`Each handler must have a string "id" (event: ${eventName})`);
|
|
70
|
+
}
|
|
71
|
+
if (seenIds.has(handler.id)) {
|
|
72
|
+
throw new Error(`Duplicate handler id: "${handler.id}"`);
|
|
73
|
+
}
|
|
74
|
+
seenIds.add(handler.id);
|
|
75
|
+
if (!handler.type || !['script', 'inline', 'llm'].includes(handler.type)) {
|
|
76
|
+
throw new Error(`Handler "${handler.id}" must have type "script", "inline", or "llm"`);
|
|
77
|
+
}
|
|
78
|
+
if (handler.type === 'script' && !('command' in handler && handler.command)) {
|
|
79
|
+
throw new Error(`Script handler "${handler.id}" must have a "command" field`);
|
|
80
|
+
}
|
|
81
|
+
if (handler.type === 'inline' && !('module' in handler && handler.module)) {
|
|
82
|
+
throw new Error(`Inline handler "${handler.id}" must have a "module" field`);
|
|
83
|
+
}
|
|
84
|
+
if (handler.type === 'llm') {
|
|
85
|
+
const llm = handler;
|
|
86
|
+
if (!llm.model) {
|
|
87
|
+
throw new Error(`LLM handler "${handler.id}" must have a "model" field`);
|
|
88
|
+
}
|
|
89
|
+
if (!llm.prompt) {
|
|
90
|
+
throw new Error(`LLM handler "${handler.id}" must have a "prompt" field`);
|
|
91
|
+
}
|
|
92
|
+
const validModels = ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-6'];
|
|
93
|
+
if (!validModels.includes(llm.model)) {
|
|
94
|
+
throw new Error(`LLM handler "${handler.id}" model must be one of: ${validModels.join(', ')}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Validate prefetch if present
|
|
100
|
+
if (manifest.prefetch !== undefined) {
|
|
101
|
+
if (!Array.isArray(manifest.prefetch)) {
|
|
102
|
+
throw new Error('prefetch must be an array');
|
|
103
|
+
}
|
|
104
|
+
const validKeys = ['transcript', 'git_status', 'git_diff'];
|
|
105
|
+
for (const key of manifest.prefetch) {
|
|
106
|
+
if (!validKeys.includes(key)) {
|
|
107
|
+
throw new Error(`Invalid prefetch key: "${key}". Valid keys: ${validKeys.join(', ')}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Load all installed plugins and return their manifests.
|
|
114
|
+
*/
|
|
115
|
+
function loadPlugins(pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
116
|
+
const registry = loadRegistry(registryPath);
|
|
117
|
+
const results = [];
|
|
118
|
+
for (const plugin of registry.plugins) {
|
|
119
|
+
const manifestPath = (0, path_1.join)(plugin.path, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
120
|
+
if (!(0, fs_1.existsSync)(manifestPath)) {
|
|
121
|
+
// Skip plugins with missing manifests (silently — doctor will catch this)
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const raw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
|
|
126
|
+
const parsed = (0, yaml_1.parse)(raw);
|
|
127
|
+
validatePluginManifest(parsed);
|
|
128
|
+
results.push({ name: plugin.name, manifest: parsed });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Skip plugins with invalid manifests
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Namespace a handler ID with a plugin name.
|
|
139
|
+
*/
|
|
140
|
+
function namespaceId(pluginName, handlerId) {
|
|
141
|
+
return `${pluginName}/${handlerId}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Merge user manifest + plugin manifests into a composite manifest.
|
|
145
|
+
* Plugin handler IDs are namespaced as "pluginName/handlerId".
|
|
146
|
+
* Prefetch keys are unioned.
|
|
147
|
+
* Settings come from user manifest only.
|
|
148
|
+
*/
|
|
149
|
+
function mergeManifests(userManifest, plugins) {
|
|
150
|
+
// Deep clone user manifest handlers
|
|
151
|
+
const merged = {
|
|
152
|
+
handlers: {},
|
|
153
|
+
prefetch: userManifest.prefetch ? [...userManifest.prefetch] : undefined,
|
|
154
|
+
settings: userManifest.settings,
|
|
155
|
+
};
|
|
156
|
+
// Copy user handlers
|
|
157
|
+
for (const [event, handlers] of Object.entries(userManifest.handlers)) {
|
|
158
|
+
if (handlers) {
|
|
159
|
+
merged.handlers[event] = [...handlers];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Merge plugin handlers
|
|
163
|
+
for (const { name: pluginName, manifest: pluginManifest } of plugins) {
|
|
164
|
+
// Union prefetch keys
|
|
165
|
+
if (pluginManifest.prefetch && pluginManifest.prefetch.length > 0) {
|
|
166
|
+
if (!merged.prefetch) {
|
|
167
|
+
merged.prefetch = [];
|
|
168
|
+
}
|
|
169
|
+
for (const key of pluginManifest.prefetch) {
|
|
170
|
+
if (!merged.prefetch.includes(key)) {
|
|
171
|
+
merged.prefetch.push(key);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Namespace and add handlers
|
|
176
|
+
for (const [event, handlers] of Object.entries(pluginManifest.handlers)) {
|
|
177
|
+
if (!handlers)
|
|
178
|
+
continue;
|
|
179
|
+
const hookEvent = event;
|
|
180
|
+
if (!merged.handlers[hookEvent]) {
|
|
181
|
+
merged.handlers[hookEvent] = [];
|
|
182
|
+
}
|
|
183
|
+
for (const handler of handlers) {
|
|
184
|
+
const namespacedHandler = {
|
|
185
|
+
...handler,
|
|
186
|
+
id: namespaceId(pluginName, handler.id),
|
|
187
|
+
};
|
|
188
|
+
// Namespace depends references too
|
|
189
|
+
if (namespacedHandler.depends) {
|
|
190
|
+
namespacedHandler.depends = namespacedHandler.depends.map(dep =>
|
|
191
|
+
// If the dep already contains a slash, it references another plugin — leave it
|
|
192
|
+
dep.includes('/') ? dep : namespaceId(pluginName, dep));
|
|
193
|
+
}
|
|
194
|
+
merged.handlers[hookEvent].push(namespacedHandler);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return merged;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Install a plugin from a local directory path.
|
|
202
|
+
* 1. Read clooks-plugin.yaml from the path
|
|
203
|
+
* 2. Validate it
|
|
204
|
+
* 3. Copy the directory to plugins dir under {name}/
|
|
205
|
+
* 4. Register in installed.json
|
|
206
|
+
* 5. Resolve $PLUGIN_DIR in handler commands to the installed path
|
|
207
|
+
*/
|
|
208
|
+
function installPlugin(sourcePath, pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
209
|
+
const resolvedSource = (0, path_1.resolve)(sourcePath);
|
|
210
|
+
const manifestPath = (0, path_1.join)(resolvedSource, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
211
|
+
if (!(0, fs_1.existsSync)(manifestPath)) {
|
|
212
|
+
throw new Error(`No ${constants_js_1.PLUGIN_MANIFEST_NAME} found at ${resolvedSource}`);
|
|
213
|
+
}
|
|
214
|
+
const raw = (0, fs_1.readFileSync)(manifestPath, 'utf-8');
|
|
215
|
+
const pluginManifest = (0, yaml_1.parse)(raw);
|
|
216
|
+
validatePluginManifest(pluginManifest);
|
|
217
|
+
const destPath = (0, path_1.join)(pluginsDir, pluginManifest.name);
|
|
218
|
+
// Ensure plugins dir exists
|
|
219
|
+
if (!(0, fs_1.existsSync)(pluginsDir)) {
|
|
220
|
+
(0, fs_1.mkdirSync)(pluginsDir, { recursive: true });
|
|
221
|
+
}
|
|
222
|
+
// Remove existing installation if present
|
|
223
|
+
if ((0, fs_1.existsSync)(destPath)) {
|
|
224
|
+
(0, fs_1.rmSync)(destPath, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
// Copy plugin directory to plugins dir
|
|
227
|
+
(0, fs_1.cpSync)(resolvedSource, destPath, { recursive: true });
|
|
228
|
+
// Resolve $PLUGIN_DIR in handler commands within the installed copy
|
|
229
|
+
const installedManifestPath = (0, path_1.join)(destPath, constants_js_1.PLUGIN_MANIFEST_NAME);
|
|
230
|
+
const installedRaw = (0, fs_1.readFileSync)(installedManifestPath, 'utf-8');
|
|
231
|
+
const resolved = installedRaw.replace(/\$PLUGIN_DIR/g, destPath);
|
|
232
|
+
(0, fs_1.writeFileSync)(installedManifestPath, resolved, 'utf-8');
|
|
233
|
+
// Resolve relative extras.readme to absolute path
|
|
234
|
+
const resolvedManifest = (0, yaml_1.parse)(resolved);
|
|
235
|
+
if (resolvedManifest.extras?.readme && !resolvedManifest.extras.readme.startsWith('/')) {
|
|
236
|
+
resolvedManifest.extras.readme = (0, path_1.join)(destPath, resolvedManifest.extras.readme);
|
|
237
|
+
(0, fs_1.writeFileSync)(installedManifestPath, (0, yaml_1.stringify)(resolvedManifest), 'utf-8');
|
|
238
|
+
}
|
|
239
|
+
// Update registry
|
|
240
|
+
const registry = loadRegistry(registryPath);
|
|
241
|
+
// Remove any existing entry for this plugin
|
|
242
|
+
registry.plugins = registry.plugins.filter(p => p.name !== pluginManifest.name);
|
|
243
|
+
const entry = {
|
|
244
|
+
name: pluginManifest.name,
|
|
245
|
+
version: pluginManifest.version,
|
|
246
|
+
path: destPath,
|
|
247
|
+
installedAt: new Date().toISOString(),
|
|
248
|
+
};
|
|
249
|
+
registry.plugins.push(entry);
|
|
250
|
+
saveRegistry(registry, registryPath);
|
|
251
|
+
return entry;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Uninstall a plugin by name.
|
|
255
|
+
* 1. Remove from installed.json
|
|
256
|
+
* 2. Delete the plugin directory
|
|
257
|
+
*/
|
|
258
|
+
function uninstallPlugin(name, pluginsDir = constants_js_1.PLUGINS_DIR, registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
259
|
+
const registry = loadRegistry(registryPath);
|
|
260
|
+
const plugin = registry.plugins.find(p => p.name === name);
|
|
261
|
+
if (!plugin) {
|
|
262
|
+
throw new Error(`Plugin "${name}" is not installed`);
|
|
263
|
+
}
|
|
264
|
+
// Remove from registry
|
|
265
|
+
registry.plugins = registry.plugins.filter(p => p.name !== name);
|
|
266
|
+
saveRegistry(registry, registryPath);
|
|
267
|
+
// Delete plugin directory
|
|
268
|
+
const pluginPath = (0, path_1.join)(pluginsDir, name);
|
|
269
|
+
if ((0, fs_1.existsSync)(pluginPath)) {
|
|
270
|
+
(0, fs_1.rmSync)(pluginPath, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* List installed plugins.
|
|
275
|
+
*/
|
|
276
|
+
function listPlugins(registryPath = constants_js_1.PLUGIN_REGISTRY) {
|
|
277
|
+
const registry = loadRegistry(registryPath);
|
|
278
|
+
return registry.plugins;
|
|
279
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class RateLimiter {
|
|
2
|
+
private attempts;
|
|
3
|
+
private maxAttempts;
|
|
4
|
+
private windowMs;
|
|
5
|
+
constructor(maxAttempts?: number, windowMs?: number);
|
|
6
|
+
/** Check if source is rate-limited. Returns true if allowed. */
|
|
7
|
+
check(source: string): boolean;
|
|
8
|
+
/** Record an attempt from source. */
|
|
9
|
+
record(source: string): void;
|
|
10
|
+
/** Clean up old entries. */
|
|
11
|
+
cleanup(): void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// clooks rate limiting — protect against auth brute-force
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.RateLimiter = void 0;
|
|
5
|
+
class RateLimiter {
|
|
6
|
+
attempts = new Map(); // source → timestamps
|
|
7
|
+
maxAttempts;
|
|
8
|
+
windowMs;
|
|
9
|
+
constructor(maxAttempts = 10, windowMs = 60_000) {
|
|
10
|
+
this.maxAttempts = maxAttempts;
|
|
11
|
+
this.windowMs = windowMs;
|
|
12
|
+
}
|
|
13
|
+
/** Check if source is rate-limited. Returns true if allowed. */
|
|
14
|
+
check(source) {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
const timestamps = this.attempts.get(source);
|
|
17
|
+
if (!timestamps)
|
|
18
|
+
return true;
|
|
19
|
+
// Count recent attempts within window
|
|
20
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
21
|
+
return recent.length < this.maxAttempts;
|
|
22
|
+
}
|
|
23
|
+
/** Record an attempt from source. */
|
|
24
|
+
record(source) {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const timestamps = this.attempts.get(source) ?? [];
|
|
27
|
+
timestamps.push(now);
|
|
28
|
+
this.attempts.set(source, timestamps);
|
|
29
|
+
}
|
|
30
|
+
/** Clean up old entries. */
|
|
31
|
+
cleanup() {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
for (const [source, timestamps] of this.attempts) {
|
|
34
|
+
const recent = timestamps.filter(t => now - t <= this.windowMs);
|
|
35
|
+
if (recent.length === 0) {
|
|
36
|
+
this.attempts.delete(source);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.attempts.set(source, recent);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
exports.RateLimiter = RateLimiter;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { type Server } from 'http';
|
|
2
|
+
import type { FSWatcher } from 'fs';
|
|
2
3
|
import { MetricsCollector } from './metrics.js';
|
|
4
|
+
import { DenyCache } from './shortcircuit.js';
|
|
5
|
+
import { RateLimiter } from './ratelimit.js';
|
|
3
6
|
import type { Manifest } from './types.js';
|
|
4
7
|
export interface ServerContext {
|
|
5
8
|
server: Server;
|
|
6
9
|
metrics: MetricsCollector;
|
|
7
10
|
startTime: number;
|
|
8
11
|
manifest: Manifest;
|
|
12
|
+
watcher?: FSWatcher;
|
|
13
|
+
denyCache: DenyCache;
|
|
14
|
+
rateLimiter: RateLimiter;
|
|
15
|
+
cleanupInterval?: ReturnType<typeof setInterval>;
|
|
9
16
|
}
|
|
10
17
|
/**
|
|
11
18
|
* Create the HTTP server for hook handling.
|
|
@@ -14,7 +21,9 @@ export declare function createServer(manifest: Manifest, metrics: MetricsCollect
|
|
|
14
21
|
/**
|
|
15
22
|
* Start the daemon: bind the server and write PID file.
|
|
16
23
|
*/
|
|
17
|
-
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector
|
|
24
|
+
export declare function startDaemon(manifest: Manifest, metrics: MetricsCollector, options?: {
|
|
25
|
+
noWatch?: boolean;
|
|
26
|
+
}): Promise<ServerContext>;
|
|
18
27
|
/**
|
|
19
28
|
* Stop a running daemon by reading PID file and sending SIGTERM.
|
|
20
29
|
*/
|
|
@@ -26,4 +35,6 @@ export declare function isDaemonRunning(): boolean;
|
|
|
26
35
|
/**
|
|
27
36
|
* Start daemon as a detached background process.
|
|
28
37
|
*/
|
|
29
|
-
export declare function startDaemonBackground(
|
|
38
|
+
export declare function startDaemonBackground(options?: {
|
|
39
|
+
noWatch?: boolean;
|
|
40
|
+
}): void;
|