dsh-hooks 0.5.0 → 0.7.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 +83 -8
- package/README.zh.md +83 -8
- package/lib/client.js +727 -23
- package/lib/feishu.d.ts +6 -0
- package/lib/feishu.js +24 -3
- package/lib/index.js +1 -1
- package/lib/notify.d.ts +2 -2
- package/lib/notify.js +3 -2
- package/lib/patch-config.d.ts +47 -0
- package/lib/patch-config.js +141 -0
- package/lib/runner.d.ts +8 -0
- package/lib/runner.js +4 -1
- package/lib/server.d.ts +34 -8
- package/lib/server.js +144 -6
- package/package.json +7 -7
package/lib/feishu.d.ts
CHANGED
|
@@ -105,12 +105,18 @@ export interface FeishuSummary {
|
|
|
105
105
|
target: string | null;
|
|
106
106
|
/** Card content truncation length (from the credential file, or the default). */
|
|
107
107
|
resultMaxChars: number;
|
|
108
|
+
/** Sample card content truncated at `resultMaxChars` (editor preview). */
|
|
109
|
+
preview: string;
|
|
108
110
|
}
|
|
111
|
+
/** Truncate the preview sample the way the notify script truncates content. */
|
|
112
|
+
export declare function truncatePreview(text: string, max: number): string;
|
|
109
113
|
/**
|
|
110
114
|
* Inspect the credential file for a display-only summary. The app secret is
|
|
111
115
|
* read for presence only and never enters any returned value.
|
|
112
116
|
*/
|
|
113
117
|
export declare function readFeishuSummary(configPath?: string): FeishuSummary;
|
|
118
|
+
/** Delete the credential file; returns whether it existed. */
|
|
119
|
+
export declare function deleteFeishuConfig(configPath?: string): boolean;
|
|
114
120
|
/**
|
|
115
121
|
* Update the card truncation length in an existing credential file, keeping
|
|
116
122
|
* every other field (credentials, target) untouched. Throws a user-facing
|
package/lib/feishu.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* time: the SDK resolves through a dynamic import on first setup, so
|
|
10
10
|
* headless/CLI profiles without a web server pay nothing for the UI path.
|
|
11
11
|
*/
|
|
12
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import YAML from 'yaml';
|
|
@@ -224,6 +224,18 @@ export function maskId(value) {
|
|
|
224
224
|
return value;
|
|
225
225
|
return `${value.slice(0, 8)}…${value.slice(-4)}`;
|
|
226
226
|
}
|
|
227
|
+
/** Sample text long enough to demonstrate any truncation length. */
|
|
228
|
+
const PREVIEW_SAMPLE = '✅ 任务已完成 · 示例会话(回合 #42)。本轮完成了依赖安装、代码实现与全部测试验证,' +
|
|
229
|
+
'飞书卡片正文会按设定的截断长度折叠,超出部分在行边界处省略并追加省略号。' +
|
|
230
|
+
'你可以用下面的长度输入框调整它,保存后立即生效,无需重启 dsh web。'.repeat(6);
|
|
231
|
+
/** Truncate the preview sample the way the notify script truncates content. */
|
|
232
|
+
export function truncatePreview(text, max) {
|
|
233
|
+
if (text.length <= max)
|
|
234
|
+
return text;
|
|
235
|
+
const cut = text.slice(0, max);
|
|
236
|
+
const lastNl = cut.lastIndexOf('\n');
|
|
237
|
+
return (lastNl > max / 2 ? cut.slice(0, lastNl) : cut) + '…';
|
|
238
|
+
}
|
|
227
239
|
/**
|
|
228
240
|
* Inspect the credential file for a display-only summary. The app secret is
|
|
229
241
|
* read for presence only and never enters any returned value.
|
|
@@ -235,6 +247,7 @@ export function readFeishuSummary(configPath = FEISHU_CONFIG_PATH) {
|
|
|
235
247
|
targetKind: null,
|
|
236
248
|
target: null,
|
|
237
249
|
resultMaxChars: FEISHU_RESULT_MAX_CHARS_DEFAULT,
|
|
250
|
+
preview: truncatePreview(PREVIEW_SAMPLE, FEISHU_RESULT_MAX_CHARS_DEFAULT),
|
|
238
251
|
};
|
|
239
252
|
if (!existsSync(configPath))
|
|
240
253
|
return empty;
|
|
@@ -247,14 +260,22 @@ export function readFeishuSummary(configPath = FEISHU_CONFIG_PATH) {
|
|
|
247
260
|
const resultMaxChars = typeof file.result_max_chars === 'number' && Number.isFinite(file.result_max_chars) && file.result_max_chars > 0
|
|
248
261
|
? Math.floor(file.result_max_chars)
|
|
249
262
|
: FEISHU_RESULT_MAX_CHARS_DEFAULT;
|
|
263
|
+
const preview = truncatePreview(PREVIEW_SAMPLE, resultMaxChars);
|
|
250
264
|
if (appId === null || !secret || target === null)
|
|
251
|
-
return { ...empty, resultMaxChars };
|
|
252
|
-
return { configured: true, appId: maskId(appId), targetKind, target: maskId(target), resultMaxChars };
|
|
265
|
+
return { ...empty, resultMaxChars, preview };
|
|
266
|
+
return { configured: true, appId: maskId(appId), targetKind, target: maskId(target), resultMaxChars, preview };
|
|
253
267
|
}
|
|
254
268
|
catch {
|
|
255
269
|
return empty;
|
|
256
270
|
}
|
|
257
271
|
}
|
|
272
|
+
/** Delete the credential file; returns whether it existed. */
|
|
273
|
+
export function deleteFeishuConfig(configPath = FEISHU_CONFIG_PATH) {
|
|
274
|
+
if (!existsSync(configPath))
|
|
275
|
+
return false;
|
|
276
|
+
unlinkSync(configPath);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
258
279
|
/**
|
|
259
280
|
* Update the card truncation length in an existing credential file, keeping
|
|
260
281
|
* every other field (credentials, target) untouched. Throws a user-facing
|
package/lib/index.js
CHANGED
|
@@ -30,7 +30,7 @@ export function apply(ctx, config = {}) {
|
|
|
30
30
|
if (webServer !== undefined) {
|
|
31
31
|
const feishu = createFeishuSetupManager();
|
|
32
32
|
ctx.effect(() => {
|
|
33
|
-
const unregister = registerHookRoutes(webServer, { hooks, history, feishu: { manager: feishu } });
|
|
33
|
+
const unregister = registerHookRoutes(webServer, { hooks, history, runner, feishu: { manager: feishu } });
|
|
34
34
|
return () => {
|
|
35
35
|
unregister();
|
|
36
36
|
// Abort an in-flight QR scan so it never outlives the plugin.
|
package/lib/notify.d.ts
CHANGED
|
@@ -24,5 +24,5 @@ export declare function sendWebhook(spec: NotifySpec, ctx: HookContext, env?: No
|
|
|
24
24
|
* through shell-string interpolation.
|
|
25
25
|
*/
|
|
26
26
|
export declare function sendDesktop(spec: NotifySpec, ctx: HookContext): Promise<NotifyResult>;
|
|
27
|
-
/** Fire a built-in notification; failures only warn. */
|
|
28
|
-
export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<
|
|
27
|
+
/** Fire a built-in notification; failures only warn and surface in the result. */
|
|
28
|
+
export declare function fireNotify(spec: NotifySpec, ctx: HookContext, record?: NotifyRecord): Promise<NotifyResult>;
|
package/lib/notify.js
CHANGED
|
@@ -196,7 +196,7 @@ function runAndWait(argv, env, timeoutMs) {
|
|
|
196
196
|
});
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
|
-
/** Fire a built-in notification; failures only warn. */
|
|
199
|
+
/** Fire a built-in notification; failures only warn and surface in the result. */
|
|
200
200
|
export async function fireNotify(spec, ctx, record) {
|
|
201
201
|
const startedAt = Date.now();
|
|
202
202
|
const result = spec.channel === 'webhook' ? await sendWebhook(spec, ctx) : await sendDesktop(spec, ctx);
|
|
@@ -212,7 +212,7 @@ export async function fireNotify(spec, ctx, record) {
|
|
|
212
212
|
durationMs: Date.now() - startedAt,
|
|
213
213
|
error: result.error,
|
|
214
214
|
});
|
|
215
|
-
return;
|
|
215
|
+
return result;
|
|
216
216
|
}
|
|
217
217
|
record?.({
|
|
218
218
|
kind: 'notify',
|
|
@@ -223,4 +223,5 @@ export async function fireNotify(spec, ctx, record) {
|
|
|
223
223
|
outcome: 'sent',
|
|
224
224
|
durationMs: Date.now() - startedAt,
|
|
225
225
|
});
|
|
226
|
+
return result;
|
|
226
227
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Wire shape for one hook as the settings panel sends it (string regexes). */
|
|
2
|
+
export interface HookWireSpec {
|
|
3
|
+
on: string;
|
|
4
|
+
when?: string;
|
|
5
|
+
match?: Record<string, string>;
|
|
6
|
+
run?: string;
|
|
7
|
+
notify?: {
|
|
8
|
+
channel: 'webhook' | 'desktop';
|
|
9
|
+
url?: string;
|
|
10
|
+
slack?: boolean;
|
|
11
|
+
} | null;
|
|
12
|
+
input?: 'env' | 'stdin';
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
retries?: number;
|
|
15
|
+
retryDelayMs?: number;
|
|
16
|
+
}
|
|
17
|
+
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
18
|
+
export declare function parsePatchText(text: string): unknown[];
|
|
19
|
+
/**
|
|
20
|
+
* Validate the wire hooks before they ever touch a file. Returns a
|
|
21
|
+
* user-facing error message, or null when every hook is valid.
|
|
22
|
+
*/
|
|
23
|
+
export declare function validateHookWire(hooks: HookWireSpec[]): string | null;
|
|
24
|
+
/**
|
|
25
|
+
* Replace the dsh-hooks block's hooks in a patch list. Other entries and
|
|
26
|
+
* other config of the dsh-hooks entry (e.g. `history`) stay untouched; a
|
|
27
|
+
* missing dsh-hooks entry is appended.
|
|
28
|
+
*/
|
|
29
|
+
export declare function patchTextWithHooks(existingText: string, hooks: HookWireSpec[]): string;
|
|
30
|
+
/** Timestamped backup path for a patch file. */
|
|
31
|
+
export declare function backupPathFor(patchFile: string, now?: Date): string;
|
|
32
|
+
export interface WriteHooksResult {
|
|
33
|
+
patchFile: string;
|
|
34
|
+
backupPath: string;
|
|
35
|
+
hookCount: number;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Validate and persist the hook list into a profile's cordis.patch.yml.
|
|
39
|
+
* The previous content is backed up beside the file first.
|
|
40
|
+
*/
|
|
41
|
+
export declare function writeHooksConfig(patchFile: string, hooks: HookWireSpec[]): WriteHooksResult;
|
|
42
|
+
/**
|
|
43
|
+
* Drop every hook whose `run` references the given script (the stable
|
|
44
|
+
* notify-feishu.mjs copy), used by the Feishu disconnect flow. Other
|
|
45
|
+
* entries and config stay untouched.
|
|
46
|
+
*/
|
|
47
|
+
export declare function removeScriptHooks(patchFile: string, scriptMarker: string): void;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch-file plumbing for the settings-panel hook editor and the Feishu
|
|
3
|
+
* disconnect flow: read a profile's cordis.patch.yml, replace the dsh-hooks
|
|
4
|
+
* block's hooks while keeping every other entry (and other dsh-hooks
|
|
5
|
+
* config, e.g. `history`) untouched, and write back with a timestamped
|
|
6
|
+
* backup. The profile layer is hot-reloaded by the harness's patch watcher,
|
|
7
|
+
* so a save applies without a restart.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import YAML from 'yaml';
|
|
11
|
+
import { HOOK_EVENTS, TURN_END_REASONS } from './config.js';
|
|
12
|
+
/** Parse a patch list; throws a user-facing error on malformed YAML. */
|
|
13
|
+
export function parsePatchText(text) {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = YAML.parse(text || '[]\n');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new Error('cordis.patch.yml 解析失败,请先修复该文件');
|
|
20
|
+
}
|
|
21
|
+
if (!Array.isArray(entries))
|
|
22
|
+
throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
|
|
23
|
+
return entries;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Validate the wire hooks before they ever touch a file. Returns a
|
|
27
|
+
* user-facing error message, or null when every hook is valid.
|
|
28
|
+
*/
|
|
29
|
+
export function validateHookWire(hooks) {
|
|
30
|
+
for (const [i, hook] of hooks.entries()) {
|
|
31
|
+
const label = `hook #${i + 1}`;
|
|
32
|
+
if (!HOOK_EVENTS.includes(hook.on)) {
|
|
33
|
+
return `${label}:无效事件 ${hook.on}`;
|
|
34
|
+
}
|
|
35
|
+
if (hook.when !== undefined && !TURN_END_REASONS.includes(hook.when)) {
|
|
36
|
+
return `${label}:无效 when 原因 ${hook.when}`;
|
|
37
|
+
}
|
|
38
|
+
if (hook.match !== undefined) {
|
|
39
|
+
for (const [field, pattern] of Object.entries(hook.match)) {
|
|
40
|
+
if (field === '')
|
|
41
|
+
return `${label}:match 字段名不能为空`;
|
|
42
|
+
try {
|
|
43
|
+
new RegExp(pattern);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return `${label}:match.${field} 正则无效(${error instanceof Error ? error.message : String(error)})`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const hasRun = typeof hook.run === 'string' && hook.run.trim() !== '';
|
|
51
|
+
const hasNotify = hook.notify !== undefined && hook.notify !== null;
|
|
52
|
+
if (hasRun === hasNotify) {
|
|
53
|
+
return `${label}:run 与 notify 必须且只能声明一个`;
|
|
54
|
+
}
|
|
55
|
+
if (hasNotify && hook.notify.channel !== 'webhook' && hook.notify.channel !== 'desktop') {
|
|
56
|
+
return `${label}:无效通知渠道 ${hook.notify.channel}`;
|
|
57
|
+
}
|
|
58
|
+
for (const key of ['timeoutMs', 'retries', 'retryDelayMs']) {
|
|
59
|
+
const value = hook[key];
|
|
60
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
|
|
61
|
+
return `${label}:${key} 必须是非负数字`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Replace the dsh-hooks block's hooks in a patch list. Other entries and
|
|
69
|
+
* other config of the dsh-hooks entry (e.g. `history`) stay untouched; a
|
|
70
|
+
* missing dsh-hooks entry is appended.
|
|
71
|
+
*/
|
|
72
|
+
export function patchTextWithHooks(existingText, hooks) {
|
|
73
|
+
const entries = parsePatchText(existingText);
|
|
74
|
+
let found = false;
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
|
|
77
|
+
const target = entry;
|
|
78
|
+
target.name = 'dsh-hooks';
|
|
79
|
+
target.config = { ...(target.config ?? {}), hooks };
|
|
80
|
+
found = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!found)
|
|
85
|
+
entries.push({ id: 'dsh-hooks', name: 'dsh-hooks', config: { hooks } });
|
|
86
|
+
return YAML.stringify(entries);
|
|
87
|
+
}
|
|
88
|
+
/** Timestamped backup path for a patch file. */
|
|
89
|
+
export function backupPathFor(patchFile, now = new Date()) {
|
|
90
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
91
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
92
|
+
return `${patchFile}.bak-${stamp}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Validate and persist the hook list into a profile's cordis.patch.yml.
|
|
96
|
+
* The previous content is backed up beside the file first.
|
|
97
|
+
*/
|
|
98
|
+
export function writeHooksConfig(patchFile, hooks) {
|
|
99
|
+
const invalid = validateHookWire(hooks);
|
|
100
|
+
if (invalid !== null)
|
|
101
|
+
throw new Error(invalid);
|
|
102
|
+
const existing = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : '[]\n';
|
|
103
|
+
const backupPath = backupPathFor(patchFile);
|
|
104
|
+
writeFileSync(backupPath, existing, 'utf8');
|
|
105
|
+
writeFileSync(patchFile, patchTextWithHooks(existing, hooks), 'utf8');
|
|
106
|
+
return { patchFile, backupPath, hookCount: hooks.length };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Drop every hook whose `run` references the given script (the stable
|
|
110
|
+
* notify-feishu.mjs copy), used by the Feishu disconnect flow. Other
|
|
111
|
+
* entries and config stay untouched.
|
|
112
|
+
*/
|
|
113
|
+
export function removeScriptHooks(patchFile, scriptMarker) {
|
|
114
|
+
if (!existsSync(patchFile))
|
|
115
|
+
return;
|
|
116
|
+
const existing = readFileSync(patchFile, 'utf8');
|
|
117
|
+
const entries = parsePatchText(existing);
|
|
118
|
+
let changed = false;
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry === null || typeof entry !== 'object' || entry.id !== 'dsh-hooks')
|
|
121
|
+
continue;
|
|
122
|
+
const config = entry.config;
|
|
123
|
+
const hooks = Array.isArray(config?.hooks) ? config.hooks : [];
|
|
124
|
+
const kept = hooks.filter((hook) => {
|
|
125
|
+
if (typeof hook.run !== 'string')
|
|
126
|
+
return true;
|
|
127
|
+
if (hook.run.includes(scriptMarker)) {
|
|
128
|
+
changed = true;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
if (changed) {
|
|
134
|
+
config.hooks = kept;
|
|
135
|
+
const backupPath = backupPathFor(patchFile);
|
|
136
|
+
writeFileSync(backupPath, existing, 'utf8');
|
|
137
|
+
writeFileSync(patchFile, YAML.stringify(entries), 'utf8');
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/lib/runner.d.ts
CHANGED
|
@@ -10,8 +10,16 @@ export interface RunOutcome {
|
|
|
10
10
|
/** Track in-flight hook runs so a missing parent never outlives teardown. */
|
|
11
11
|
export interface HookRunner {
|
|
12
12
|
run(spec: HookSpec, ctx: HookContext): RunOutcome;
|
|
13
|
+
/** Live counters for the web-panel diagnostics. */
|
|
14
|
+
stats(): HookRunnerStats;
|
|
13
15
|
dispose(): void;
|
|
14
16
|
}
|
|
17
|
+
export interface HookRunnerStats {
|
|
18
|
+
/** Spawned children still running (waiting for their exit). */
|
|
19
|
+
inFlight: number;
|
|
20
|
+
/** Retry timers scheduled in the background. */
|
|
21
|
+
pendingRetries: number;
|
|
22
|
+
}
|
|
15
23
|
export type RunRecord = (record: Omit<HookRunRecord, 'ts'>) => void;
|
|
16
24
|
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
17
25
|
export declare const DEFAULT_RETRY_DELAY_MS = 500;
|
package/lib/runner.js
CHANGED
|
@@ -142,5 +142,8 @@ export function createHookRunner(log = console.log, record) {
|
|
|
142
142
|
terminate(child);
|
|
143
143
|
children.clear();
|
|
144
144
|
}
|
|
145
|
-
|
|
145
|
+
function stats() {
|
|
146
|
+
return { inFlight: children.size, pendingRetries: pendingRetries.size };
|
|
147
|
+
}
|
|
148
|
+
return { run, stats, dispose };
|
|
146
149
|
}
|
package/lib/server.d.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /dsh-hooks/* HTTP routes for the web profile: status
|
|
3
|
-
* a dry-run-style test
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* /dsh-hooks/* HTTP routes for the web profile: status (incl. the hook
|
|
3
|
+
* list and live runner stats), execution history, a dry-run-style test
|
|
4
|
+
* trigger, notify-channel quick tests, the hook-list editor (writes back
|
|
5
|
+
* to the profile's cordis.patch.yml with a backup), and the Feishu connect
|
|
6
|
+
* flow (QR setup / cancel / config / test card / disconnect). Registered
|
|
7
|
+
* only when the shared webserver service exists (web profile) — CLI/headless
|
|
8
|
+
* environments never see them. Loopback-only by default, with JSON envelopes; POSTs
|
|
9
|
+
* require an explicit application/json content-type (CSRF hardening, same
|
|
10
|
+
* posture as dsh-aionui-panel).
|
|
9
11
|
*/
|
|
10
12
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
11
13
|
import type { HookSpec } from './config.js';
|
|
12
14
|
import type { HistorySink } from './history.js';
|
|
15
|
+
import { type HookRunner } from './runner.js';
|
|
13
16
|
import { type FeishuSetupManager } from './feishu-session.js';
|
|
14
17
|
import { runFeishuTest } from './feishu.js';
|
|
15
18
|
/** Minimal structural shape of the shared web server (dsh-host-webserver). */
|
|
@@ -22,7 +25,7 @@ export interface WebServerLike {
|
|
|
22
25
|
}
|
|
23
26
|
/** Plugin version, read from package.json (this package ships its own). */
|
|
24
27
|
export declare function pluginVersion(): string;
|
|
25
|
-
/**
|
|
28
|
+
/** DSH_HOOKS_ALLOWED_IPS: unset/empty = loopback; * = any; otherwise comma-separated IPs. */
|
|
26
29
|
export declare function isLoopbackRequest(req: IncomingMessage): boolean;
|
|
27
30
|
export interface FeishuRouteDeps {
|
|
28
31
|
/** QR-scan session manager (one in-flight flow at a time). */
|
|
@@ -37,7 +40,30 @@ export interface HookRoutesOptions {
|
|
|
37
40
|
history: HistorySink;
|
|
38
41
|
version?: string;
|
|
39
42
|
feishu?: FeishuRouteDeps;
|
|
43
|
+
/** Live runner counters for the diagnostics badge. */
|
|
44
|
+
runner?: Pick<HookRunner, 'stats'>;
|
|
45
|
+
/** Profile → patch-file resolver, injectable so tests never touch the real home. */
|
|
46
|
+
resolvePatchFile?: (profile: string) => string;
|
|
40
47
|
}
|
|
48
|
+
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
49
|
+
export declare function describeHooks(hooks: readonly HookSpec[]): {
|
|
50
|
+
index: number;
|
|
51
|
+
on: "agent/created" | "agent/disposed" | "agent/error" | "agent/status" | "approval/asked" | "session/created" | "session/disposed" | "session/title" | "step/end" | "tool/call" | "tool/result" | "turn/end" | "turn/start" | "user/message";
|
|
52
|
+
when: "aborted" | "blocked" | "completed" | "error" | "interrupted" | "max-tokens" | undefined;
|
|
53
|
+
match: {
|
|
54
|
+
[k: string]: string;
|
|
55
|
+
} | undefined;
|
|
56
|
+
run: string | undefined;
|
|
57
|
+
notify: {
|
|
58
|
+
channel: "desktop" | "webhook";
|
|
59
|
+
url: string | undefined;
|
|
60
|
+
slack: boolean | undefined;
|
|
61
|
+
} | undefined;
|
|
62
|
+
input: "env" | "stdin" | undefined;
|
|
63
|
+
timeoutMs: number | undefined;
|
|
64
|
+
retries: number | undefined;
|
|
65
|
+
retryDelayMs: number | undefined;
|
|
66
|
+
}[];
|
|
41
67
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
42
68
|
export declare function createHookHandler(options: HookRoutesOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
43
69
|
/** Register the /dsh-hooks prefix route on the shared web server. */
|
package/lib/server.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { describeHook, evaluateHooks, mockContext } from './dry-run.js';
|
|
2
|
+
import { describeHook, evaluateHooks, mockContext, patchFilePath } from './dry-run.js';
|
|
3
3
|
import { createHookRunner } from './runner.js';
|
|
4
|
-
import { fireNotify } from './notify.js';
|
|
4
|
+
import { fireNotify, summarizeContext } from './notify.js';
|
|
5
5
|
import { FEISHU_SETUP_BUSY } from './feishu-session.js';
|
|
6
|
-
import { readFeishuSummary, runFeishuTest, updateFeishuResultMaxChars } from './feishu.js';
|
|
6
|
+
import { deleteFeishuConfig, readFeishuSummary, runFeishuTest, updateFeishuResultMaxChars } from './feishu.js';
|
|
7
|
+
import { removeScriptHooks, writeHooksConfig } from './patch-config.js';
|
|
7
8
|
/** Plugin version, read from package.json (this package ships its own). */
|
|
8
9
|
export function pluginVersion() {
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
@@ -21,9 +22,17 @@ function json(res, envelope, status = 200) {
|
|
|
21
22
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
22
23
|
res.end(JSON.stringify(envelope));
|
|
23
24
|
}
|
|
24
|
-
/**
|
|
25
|
+
/** DSH_HOOKS_ALLOWED_IPS: unset/empty = loopback; * = any; otherwise comma-separated IPs. */
|
|
25
26
|
export function isLoopbackRequest(req) {
|
|
27
|
+
const allowedIps = process.env.DSH_HOOKS_ALLOWED_IPS?.trim() ?? '';
|
|
28
|
+
if (allowedIps === '*')
|
|
29
|
+
return true;
|
|
30
|
+
// Check the direct peer only; never trust forwarded headers.
|
|
26
31
|
const address = req.socket.remoteAddress ?? '';
|
|
32
|
+
if (allowedIps !== '') {
|
|
33
|
+
const normalize = (ip) => ip.trim().toLowerCase().replace(/^::ffff:/, '');
|
|
34
|
+
return address !== '' && allowedIps.split(',').some((ip) => normalize(ip) === normalize(address));
|
|
35
|
+
}
|
|
27
36
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
28
37
|
}
|
|
29
38
|
async function readJsonBody(req) {
|
|
@@ -46,6 +55,26 @@ async function readJsonBody(req) {
|
|
|
46
55
|
return null;
|
|
47
56
|
}
|
|
48
57
|
}
|
|
58
|
+
/** Sanitized per-hook description for the settings panel (regex sources, no RegExp objects). */
|
|
59
|
+
export function describeHooks(hooks) {
|
|
60
|
+
return hooks.map((hook, i) => ({
|
|
61
|
+
index: i + 1,
|
|
62
|
+
on: hook.on,
|
|
63
|
+
when: hook.when,
|
|
64
|
+
match: hook.match === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: Object.fromEntries(Object.entries(hook.match).map(([field, re]) => [field, re.source])),
|
|
67
|
+
run: hook.run,
|
|
68
|
+
notify: hook.notify === undefined || hook.notify === null
|
|
69
|
+
? undefined
|
|
70
|
+
: { channel: hook.notify.channel, url: hook.notify.url, slack: hook.notify.slack },
|
|
71
|
+
input: hook.input,
|
|
72
|
+
timeoutMs: hook.timeoutMs,
|
|
73
|
+
retries: hook.retries,
|
|
74
|
+
retryDelayMs: hook.retryDelayMs,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
const FAILED_OUTCOMES = new Set(['exit-nonzero', 'timeout', 'spawn-failed', 'send-failed']);
|
|
49
78
|
/** Create the /dsh-hooks route handler (exported for tests). */
|
|
50
79
|
export function createHookHandler(options) {
|
|
51
80
|
const { hooks, history } = options;
|
|
@@ -53,9 +82,11 @@ export function createHookHandler(options) {
|
|
|
53
82
|
const feishu = options.feishu;
|
|
54
83
|
const runFeishuTestCard = feishu?.runTest ?? runFeishuTest;
|
|
55
84
|
const feishuConfigPath = feishu?.configPath;
|
|
85
|
+
const runnerStats = options.runner?.stats ?? (() => ({ inFlight: 0, pendingRetries: 0 }));
|
|
86
|
+
const resolvePatch = options.resolvePatchFile ?? patchFilePath;
|
|
56
87
|
return async (req, res) => {
|
|
57
88
|
if (!isLoopbackRequest(req)) {
|
|
58
|
-
json(res, FAIL('forbidden', '
|
|
89
|
+
json(res, FAIL('forbidden', 'IP not allowed'), 403);
|
|
59
90
|
return;
|
|
60
91
|
}
|
|
61
92
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
@@ -64,7 +95,16 @@ export function createHookHandler(options) {
|
|
|
64
95
|
// Pull in disk records (pre-restart and other-process appends) so the
|
|
65
96
|
// badge reflects the durable log, not just this process's memory.
|
|
66
97
|
history.sync();
|
|
67
|
-
|
|
98
|
+
const records = history.recent();
|
|
99
|
+
const recentFailures = records.filter((record) => FAILED_OUTCOMES.has(record.outcome)).length;
|
|
100
|
+
json(res, OK({
|
|
101
|
+
name: 'dsh-hooks',
|
|
102
|
+
version,
|
|
103
|
+
hookCount: hooks.length,
|
|
104
|
+
historyCount: records.length,
|
|
105
|
+
hooks: describeHooks(hooks),
|
|
106
|
+
stats: { ...runnerStats(), recentFailures },
|
|
107
|
+
}));
|
|
68
108
|
return;
|
|
69
109
|
}
|
|
70
110
|
if (req.method === 'GET' && pathname === '/dsh-hooks/history') {
|
|
@@ -212,6 +252,104 @@ export function createHookHandler(options) {
|
|
|
212
252
|
}
|
|
213
253
|
return;
|
|
214
254
|
}
|
|
255
|
+
if (req.method === 'POST' && pathname === '/dsh-hooks/notify/test') {
|
|
256
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
257
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
258
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const payload = await readJsonBody(req);
|
|
262
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
263
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const body = payload;
|
|
267
|
+
const channel = body.channel;
|
|
268
|
+
if (channel !== 'webhook' && channel !== 'desktop') {
|
|
269
|
+
json(res, FAIL('bad-request', '缺少字段 channel(webhook 或 desktop)'), 400);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const ctx = {
|
|
273
|
+
event: 'user/message',
|
|
274
|
+
sessionId: 'notify-test',
|
|
275
|
+
sessionName: '通知测试',
|
|
276
|
+
source: 'plugin',
|
|
277
|
+
content: '这是一条 dsh-hooks 测试通知:如果收到这条消息,说明该渠道配置正常。',
|
|
278
|
+
timestamp: new Date().toISOString(),
|
|
279
|
+
};
|
|
280
|
+
const result = await fireNotify({
|
|
281
|
+
channel,
|
|
282
|
+
url: typeof body.url === 'string' && body.url !== '' ? body.url : undefined,
|
|
283
|
+
slack: body.slack === true,
|
|
284
|
+
}, ctx, (record) => history.record(record));
|
|
285
|
+
if (!result.ok) {
|
|
286
|
+
json(res, FAIL('send-failed', result.error ?? '发送失败'), 500);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
json(res, OK({ message: '✅ 测试通知已发送', preview: summarizeContext(ctx) }));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (req.method === 'POST' && pathname === '/dsh-hooks/hooks/save') {
|
|
293
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
294
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
295
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const payload = await readJsonBody(req);
|
|
299
|
+
if (typeof payload !== 'object' || payload === null) {
|
|
300
|
+
json(res, FAIL('bad-request', 'malformed JSON body'), 400);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const body = payload;
|
|
304
|
+
const profile = typeof body.profile === 'string' && body.profile.trim() !== '' ? body.profile.trim() : 'web';
|
|
305
|
+
const wireHooks = body.hooks;
|
|
306
|
+
if (!Array.isArray(wireHooks)) {
|
|
307
|
+
json(res, FAIL('bad-request', '缺少数组字段 hooks'), 400);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const patchFile = resolvePatch(profile);
|
|
311
|
+
try {
|
|
312
|
+
const result = writeHooksConfig(patchFile, wireHooks);
|
|
313
|
+
json(res, OK({
|
|
314
|
+
profile,
|
|
315
|
+
hookCount: result.hookCount,
|
|
316
|
+
patchFile: result.patchFile,
|
|
317
|
+
backupPath: result.backupPath,
|
|
318
|
+
message: `✅ 已保存 ${result.hookCount} 个 hook 到 ${profile} profile(已备份原文件)。若未立即生效请重启 dsh web。`,
|
|
319
|
+
}));
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
323
|
+
json(res, FAIL('save-failed', message), 400);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (feishu !== undefined && req.method === 'POST' && pathname === '/dsh-hooks/feishu/disconnect') {
|
|
328
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
329
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
330
|
+
json(res, FAIL('bad-request', 'POST 需要 application/json'), 415);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const payload = await readJsonBody(req);
|
|
334
|
+
const body = (typeof payload === 'object' && payload !== null ? payload : {});
|
|
335
|
+
const profile = typeof body.profile === 'string' && body.profile.trim() !== '' ? body.profile.trim() : 'web';
|
|
336
|
+
const removeHooks = body.removeHooks === true;
|
|
337
|
+
// Abort any in-flight scan session first.
|
|
338
|
+
feishu.manager.cancel();
|
|
339
|
+
const existed = deleteFeishuConfig(feishuConfigPath);
|
|
340
|
+
if (removeHooks) {
|
|
341
|
+
try {
|
|
342
|
+
removeScriptHooks(resolvePatch(profile), 'notify-feishu.mjs');
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
346
|
+
json(res, FAIL('save-failed', message), 400);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
json(res, OK({ disconnected: true, existed, removedHooks: removeHooks, message: '✅ 已断开飞书连接' }));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
215
353
|
json(res, FAIL('not-found', `unknown route ${pathname}`), 404);
|
|
216
354
|
};
|
|
217
355
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"packageManager": "pnpm@11.21.0",
|
|
5
|
-
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + Feishu connect).",
|
|
5
|
+
"description": "Config-driven lifecycle hooks plugin for DeepSeek Harness: declare event -> command hooks in cordis.patch.yml, no plugin code required. Includes a Hooks section in the Web GUI settings (history timeline + manual tester + notify tests + hook editor + Feishu connect).",
|
|
6
6
|
"author": "PeterBon",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
@@ -73,10 +73,10 @@
|
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
76
|
-
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.
|
|
79
|
-
"@deepseek-ai/dsh-session": "^0.1.0-rc.
|
|
76
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.8",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.8",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.8",
|
|
79
|
+
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
|
80
80
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
81
81
|
"@tsdown/css": "^0.22.14",
|
|
82
82
|
"@types/node": "^26.2.0",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"react-dom": "^18.3.1",
|
|
87
87
|
"tsdown": "^0.22.2",
|
|
88
88
|
"typescript": "^7.0.2",
|
|
89
|
-
"vitest": "^4.1.
|
|
89
|
+
"vitest": "^4.1.11"
|
|
90
90
|
},
|
|
91
91
|
"dependencies": {
|
|
92
92
|
"@larksuiteoapi/node-sdk": "^1.73.0",
|