@vibe-forge/core 0.7.5 → 0.8.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.
Files changed (43) hide show
  1. package/package.json +4 -46
  2. package/src/env.ts +5 -25
  3. package/src/index.ts +0 -5
  4. package/src/types.ts +12 -72
  5. package/src/ws.ts +3 -12
  6. package/src/adapter/index.ts +0 -6
  7. package/src/adapter/loader.ts +0 -11
  8. package/src/adapter/type.ts +0 -117
  9. package/src/config/load.ts +0 -122
  10. package/src/config/types.ts +0 -289
  11. package/src/config.ts +0 -2
  12. package/src/controllers/benchmark/discover.ts +0 -89
  13. package/src/controllers/benchmark/index.ts +0 -24
  14. package/src/controllers/benchmark/result-store.ts +0 -46
  15. package/src/controllers/benchmark/runner.ts +0 -415
  16. package/src/controllers/benchmark/schema.ts +0 -60
  17. package/src/controllers/benchmark/types.ts +0 -80
  18. package/src/controllers/benchmark/utils.ts +0 -144
  19. package/src/controllers/benchmark/workspace.ts +0 -179
  20. package/src/controllers/config/index.ts +0 -214
  21. package/src/controllers/system/assets/completed.mp3 +0 -0
  22. package/src/controllers/system/assets/mcp.png +0 -0
  23. package/src/controllers/system/index.ts +0 -102
  24. package/src/controllers/task/generate-adapter-query-options.ts +0 -25
  25. package/src/controllers/task/index.ts +0 -2
  26. package/src/controllers/task/prepare.ts +0 -74
  27. package/src/controllers/task/run.ts +0 -231
  28. package/src/controllers/task/schema.ts +0 -131
  29. package/src/controllers/task/type.ts +0 -6
  30. package/src/hooks/bridge.ts +0 -368
  31. package/src/hooks/call.ts +0 -74
  32. package/src/hooks/index.ts +0 -41
  33. package/src/hooks/loader.ts +0 -79
  34. package/src/hooks/native.ts +0 -116
  35. package/src/hooks/runtime.ts +0 -139
  36. package/src/hooks/type.ts +0 -145
  37. package/src/utils/cache.ts +0 -58
  38. package/src/utils/create-logger.ts +0 -89
  39. package/src/utils/definition-loader.ts +0 -530
  40. package/src/utils/filter.ts +0 -26
  41. package/src/utils/string-transform.ts +0 -37
  42. package/src/utils/uuid.ts +0 -6
  43. package/src/utils/workspace-assets.ts +0 -919
@@ -1,368 +0,0 @@
1
- import type {
2
- AdapterCtx,
3
- AdapterEvent,
4
- AdapterOutputEvent,
5
- AdapterQueryOptions,
6
- AdapterSession
7
- } from '#~/adapter/index.js'
8
- import type { ChatMessage, ChatMessageContent } from '#~/types.js'
9
- import type { HookInputs } from './type'
10
-
11
- import { callHook } from './call'
12
-
13
- const normalizeText = (value: unknown) => (
14
- typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
15
- )
16
-
17
- const stringifyUnknown = (value: unknown) => {
18
- try {
19
- return JSON.stringify(value)
20
- } catch {
21
- return String(value)
22
- }
23
- }
24
-
25
- const describeContentPart = (item: ChatMessageContent): string | undefined => {
26
- switch (item.type) {
27
- case 'text':
28
- return normalizeText(item.text)
29
- case 'image':
30
- return normalizeText(item.name) != null ? `[Image: ${item.name}]` : '[Image attachment]'
31
- case 'tool_use':
32
- return `Tool request: ${item.name}`
33
- case 'tool_result':
34
- return typeof item.content === 'string' ? normalizeText(item.content) : stringifyUnknown(item.content)
35
- default:
36
- return undefined
37
- }
38
- }
39
-
40
- const extractPromptText = (content: AdapterEvent['content']) => {
41
- const parts = content
42
- .map(describeContentPart)
43
- .filter((value): value is string => value != null)
44
-
45
- return parts.length > 0 ? parts.join('\n\n') : undefined
46
- }
47
-
48
- const extractAssistantText = (message: ChatMessage | undefined) => {
49
- if (message == null) return undefined
50
- if (typeof message.content === 'string') return normalizeText(message.content)
51
-
52
- const parts = message.content
53
- .filter((item): item is Extract<ChatMessageContent, { type: 'text' }> => item.type === 'text')
54
- .map(item => normalizeText(item.text))
55
- .filter((value): value is string => value != null)
56
-
57
- return parts.length > 0 ? parts.join('') : undefined
58
- }
59
-
60
- const isToolUse = (
61
- item: ChatMessageContent
62
- ): item is Extract<ChatMessageContent, { type: 'tool_use' }> => item.type === 'tool_use'
63
-
64
- const isToolResult = (
65
- item: ChatMessageContent
66
- ): item is Extract<ChatMessageContent, { type: 'tool_result' }> => item.type === 'tool_result'
67
-
68
- interface HookOutputLike {
69
- continue?: boolean
70
- stopReason?: string
71
- }
72
-
73
- export interface AdapterHookBridge {
74
- start: () => Promise<void>
75
- prepareInitialPrompt: (prompt?: string) => Promise<string | undefined>
76
- wrapSession: (session: AdapterSession) => AdapterSession
77
- handleOutput: (event: AdapterOutputEvent) => void
78
- }
79
-
80
- export const createAdapterHookBridge = (params: {
81
- ctx: Pick<AdapterCtx, 'cwd' | 'env' | 'logger'>
82
- adapter: string
83
- runtime: AdapterQueryOptions['runtime']
84
- sessionId: string
85
- type: AdapterQueryOptions['type']
86
- model?: string
87
- disabledEvents?: Array<keyof HookInputs>
88
- }): AdapterHookBridge => {
89
- const {
90
- ctx,
91
- adapter,
92
- runtime,
93
- sessionId,
94
- type,
95
- model,
96
- disabledEvents: rawDisabledEvents = []
97
- } = params
98
- const disabledEvents = new Set(rawDisabledEvents)
99
-
100
- const pendingToolCalls = new Map<string, { toolName: string; toolInput?: unknown }>()
101
- let emitQueue = Promise.resolve()
102
- let killRequested = false
103
- let lastAssistantMessage: string | undefined
104
- let sessionEnded = false
105
-
106
- const callBridgeHook = async (
107
- eventName: Parameters<typeof callHook>[0],
108
- input: Record<string, unknown>,
109
- options: {
110
- canBlock: boolean
111
- enforce?: boolean
112
- blockedMessage: string
113
- }
114
- ): Promise<HookOutputLike | undefined> => {
115
- if (disabledEvents.has(eventName as keyof HookInputs)) {
116
- return undefined
117
- }
118
-
119
- try {
120
- const output = await callHook(
121
- eventName as never,
122
- {
123
- cwd: ctx.cwd,
124
- sessionId,
125
- adapter,
126
- runtime,
127
- hookSource: 'bridge',
128
- canBlock: options.canBlock,
129
- ...input
130
- } as never,
131
- ctx.env
132
- ) as HookOutputLike
133
-
134
- if (options.enforce && output?.continue === false) {
135
- throw new Error(output.stopReason ?? options.blockedMessage)
136
- }
137
-
138
- if (!options.canBlock && output?.continue === false) {
139
- ctx.logger.warn(
140
- `[HookBridge] Ignoring blocking output from observational ${String(eventName)} hook`,
141
- output.stopReason
142
- )
143
- }
144
-
145
- return output
146
- } catch (error) {
147
- ctx.logger.error(`[HookBridge] ${String(eventName)} failed`, error)
148
- if (options.enforce) throw error
149
- return undefined
150
- }
151
- }
152
-
153
- const observeMessage = (message: ChatMessage) => {
154
- const assistantText = extractAssistantText(message)
155
- if (assistantText != null) lastAssistantMessage = assistantText
156
- const preToolUseDisabled = disabledEvents.has('PreToolUse')
157
- const postToolUseDisabled = disabledEvents.has('PostToolUse')
158
- const needsPendingToolCalls = !postToolUseDisabled
159
-
160
- if (Array.isArray(message.content)) {
161
- for (const item of message.content) {
162
- if (isToolUse(item)) {
163
- if (needsPendingToolCalls) {
164
- pendingToolCalls.set(item.id, {
165
- toolName: item.name,
166
- toolInput: item.input
167
- })
168
- }
169
- if (!preToolUseDisabled) {
170
- void callBridgeHook(
171
- 'PreToolUse',
172
- {
173
- toolCallId: item.id,
174
- toolName: item.name,
175
- toolInput: item.input
176
- },
177
- {
178
- canBlock: false,
179
- blockedMessage: 'PreToolUse hook attempted to block an observed tool call'
180
- }
181
- )
182
- }
183
- }
184
-
185
- if (isToolResult(item)) {
186
- const pending = pendingToolCalls.get(item.tool_use_id)
187
- pendingToolCalls.delete(item.tool_use_id)
188
- if (!postToolUseDisabled) {
189
- void callBridgeHook(
190
- 'PostToolUse',
191
- {
192
- toolCallId: item.tool_use_id,
193
- toolName: pending?.toolName ?? 'unknown',
194
- toolInput: pending?.toolInput,
195
- toolResponse: item.content,
196
- isError: item.is_error ?? false
197
- },
198
- {
199
- canBlock: false,
200
- blockedMessage: 'PostToolUse hook attempted to block an observed tool result'
201
- }
202
- )
203
- }
204
- }
205
- }
206
- }
207
-
208
- if (message.toolCall?.name != null) {
209
- const toolCallId = message.toolCall.id ?? message.id
210
- if (needsPendingToolCalls && !pendingToolCalls.has(toolCallId)) {
211
- pendingToolCalls.set(toolCallId, {
212
- toolName: message.toolCall.name,
213
- toolInput: message.toolCall.args
214
- })
215
- }
216
-
217
- if (!preToolUseDisabled) {
218
- void callBridgeHook(
219
- 'PreToolUse',
220
- {
221
- toolCallId,
222
- toolName: message.toolCall.name,
223
- toolInput: message.toolCall.args
224
- },
225
- {
226
- canBlock: false,
227
- blockedMessage: 'PreToolUse hook attempted to block a legacy observed tool call'
228
- }
229
- )
230
- }
231
-
232
- if (!postToolUseDisabled && (message.toolCall.output != null || message.toolCall.status === 'error')) {
233
- pendingToolCalls.delete(toolCallId)
234
- void callBridgeHook(
235
- 'PostToolUse',
236
- {
237
- toolCallId,
238
- toolName: message.toolCall.name,
239
- toolInput: message.toolCall.args,
240
- toolResponse: message.toolCall.output,
241
- isError: message.toolCall.status === 'error'
242
- },
243
- {
244
- canBlock: false,
245
- blockedMessage: 'PostToolUse hook attempted to block a legacy observed tool result'
246
- }
247
- )
248
- }
249
- }
250
- }
251
-
252
- return {
253
- start: async () => {
254
- if (disabledEvents.has('SessionStart')) return
255
- await callBridgeHook(
256
- 'SessionStart',
257
- {
258
- source: type === 'resume' ? 'resume' : 'startup',
259
- model
260
- },
261
- {
262
- canBlock: true,
263
- enforce: true,
264
- blockedMessage: 'SessionStart hook blocked session startup'
265
- }
266
- )
267
- },
268
- prepareInitialPrompt: async (prompt) => {
269
- const normalizedPrompt = normalizeText(prompt)
270
- if (normalizedPrompt == null) return normalizedPrompt
271
- if (disabledEvents.has('UserPromptSubmit')) return normalizedPrompt
272
-
273
- await callBridgeHook(
274
- 'UserPromptSubmit',
275
- { prompt: normalizedPrompt },
276
- {
277
- canBlock: true,
278
- enforce: true,
279
- blockedMessage: 'UserPromptSubmit hook blocked the initial prompt'
280
- }
281
- )
282
-
283
- return normalizedPrompt
284
- },
285
- wrapSession: (session) => ({
286
- kill: () => {
287
- killRequested = true
288
- session.kill()
289
- },
290
- emit: (event) => {
291
- emitQueue = emitQueue
292
- .catch((error) => {
293
- ctx.logger.error('[HookBridge] emit queue failed', error)
294
- })
295
- .then(async () => {
296
- if (event.type === 'message') {
297
- const prompt = extractPromptText(event.content)
298
- if (prompt != null && !disabledEvents.has('UserPromptSubmit')) {
299
- const output = await callBridgeHook(
300
- 'UserPromptSubmit',
301
- { prompt },
302
- {
303
- canBlock: true,
304
- blockedMessage: 'UserPromptSubmit hook blocked the outgoing prompt'
305
- }
306
- )
307
-
308
- if (output?.continue === false) {
309
- ctx.logger.warn(
310
- '[HookBridge] Dropping outgoing message blocked by UserPromptSubmit hook',
311
- output.stopReason
312
- )
313
- return
314
- }
315
- }
316
- }
317
-
318
- session.emit(event)
319
- })
320
- },
321
- get pid() {
322
- return session.pid
323
- }
324
- }),
325
- handleOutput: (event) => {
326
- if (event.type === 'message' && event.data.role === 'assistant') {
327
- observeMessage(event.data)
328
- return
329
- }
330
-
331
- if (event.type === 'stop') {
332
- if (disabledEvents.has('Stop')) return
333
- void callBridgeHook(
334
- 'Stop',
335
- {
336
- lastAssistantMessage: extractAssistantText(event.data) ?? lastAssistantMessage
337
- },
338
- {
339
- canBlock: false,
340
- blockedMessage: 'Stop hook attempted to control an observed stop event'
341
- }
342
- )
343
- return
344
- }
345
-
346
- if (event.type === 'exit' && !sessionEnded) {
347
- sessionEnded = true
348
- void callBridgeHook(
349
- 'SessionEnd',
350
- {
351
- reason: killRequested
352
- ? 'terminated'
353
- : (event.data.exitCode ?? 0) === 0
354
- ? 'completed'
355
- : 'failed',
356
- exitCode: event.data.exitCode,
357
- stderr: event.data.stderr,
358
- lastAssistantMessage
359
- },
360
- {
361
- canBlock: false,
362
- blockedMessage: 'SessionEnd hook attempted to control an observed session end'
363
- }
364
- )
365
- }
366
- }
367
- }
368
- }
package/src/hooks/call.ts DELETED
@@ -1,74 +0,0 @@
1
- import { Buffer } from 'node:buffer'
2
- import { spawn } from 'node:child_process'
3
- import path from 'node:path'
4
- import process from 'node:process'
5
-
6
- import type { HookInputs, HookOutputs } from './type'
7
-
8
- export type HookEventName = keyof HookInputs
9
-
10
- type HookInputPayload<K extends HookEventName> = Omit<HookInputs[K], 'hookEventName'>
11
-
12
- const pickHookEnv = (env: Record<string, unknown>): Record<string, string> => {
13
- const result: Record<string, string> = {}
14
- for (const [key, value] of Object.entries(env)) {
15
- if (typeof value === 'string') {
16
- result[key] = value
17
- }
18
- }
19
- return result
20
- }
21
-
22
- const resolveHookCliJs = () => {
23
- try {
24
- const pkgJsonPath = require.resolve('@vibe-forge/cli/package.json')
25
- return path.resolve(path.dirname(pkgJsonPath), 'call-hook.js')
26
- } catch (error) {
27
- throw new Error('Failed to resolve @vibe-forge/cli hook entry', { cause: error })
28
- }
29
- }
30
-
31
- export const callHook = async <K extends HookEventName>(
32
- hookEventName: K,
33
- input: HookInputPayload<K>,
34
- env: Record<string, unknown> = process.env
35
- ): Promise<HookOutputs[K]> => {
36
- const childEnv = pickHookEnv(env)
37
- const child = spawn(process.execPath, [resolveHookCliJs()], {
38
- cwd: typeof input.cwd === 'string' ? input.cwd : process.cwd(),
39
- env: childEnv,
40
- stdio: ['pipe', 'pipe', 'pipe']
41
- })
42
-
43
- const stdoutChunks: Buffer[] = []
44
- const stderrChunks: Buffer[] = []
45
-
46
- child.stdout.on('data', chunk => stdoutChunks.push(chunk))
47
- child.stderr.on('data', chunk => stderrChunks.push(chunk))
48
-
49
- const exitCode = await new Promise<number>((resolve, reject) => {
50
- child.once('error', reject)
51
- child.once('close', code => resolve(code ?? 0))
52
- child.stdin.end(JSON.stringify({
53
- ...input,
54
- hookEventName
55
- }))
56
- })
57
-
58
- const stdout = Buffer.concat(stdoutChunks).toString('utf-8').trim()
59
- const stderr = Buffer.concat(stderrChunks).toString('utf-8').trim()
60
-
61
- if (exitCode !== 0) {
62
- throw new Error(`Failed to call hook: process exited with code ${exitCode}${stderr ? ` - ${stderr}` : ''}`)
63
- }
64
-
65
- if (stdout === '') {
66
- return { continue: true } as HookOutputs[K]
67
- }
68
-
69
- try {
70
- return JSON.parse(stdout) as HookOutputs[K]
71
- } catch (error) {
72
- throw new Error(`Failed to parse hook output: ${stdout}${stderr ? `\nstderr: ${stderr}` : ''}`, { cause: error })
73
- }
74
- }
@@ -1,41 +0,0 @@
1
- import type { Writable } from 'node:stream'
2
-
3
- import type { HookInputs, HookOutputs } from './type'
4
-
5
- export interface HookContext {
6
- logger: {
7
- stream: Writable
8
- info: (...args: unknown[]) => void
9
- warn: (...args: unknown[]) => void
10
- debug: (...args: unknown[]) => void
11
- error: (...args: unknown[]) => void
12
- }
13
- }
14
-
15
- export type Plugin =
16
- & {
17
- name?: string
18
- }
19
- & {
20
- [P in keyof HookInputs]: (
21
- ctx: HookContext,
22
- input: HookInputs[P],
23
- next: () => Promise<HookOutputs[P]>
24
- ) => Promise<HookOutputs[P]>
25
- }
26
-
27
- export const definePlugin = (plugin: Partial<Plugin>) => plugin
28
-
29
- export interface PluginMap {}
30
-
31
- export type PluginConfig =
32
- | (Partial<Plugin> | (() => Partial<Plugin>))[]
33
- | Record<string, Record<string, unknown>>
34
- | Partial<PluginMap>
35
-
36
- export * from './call'
37
- export * from './bridge'
38
- export * from './loader'
39
- export * from './native'
40
- export * from './runtime'
41
- export * from './type'
@@ -1,79 +0,0 @@
1
- import type { Plugin, PluginConfig } from './index'
2
-
3
- /**
4
- * 解析单个插件配置
5
- */
6
- const loadPlugin = async (
7
- name: string,
8
- config: Record<string, unknown>
9
- ): Promise<Partial<Plugin> | null> => {
10
- try {
11
- // eslint-disable-next-line ts/no-require-imports
12
- const module = require(`${name}/hooks`)
13
-
14
- // 兼容 ESM default export 和 CJS module.exports
15
- const factory: (
16
- // 直接导出插件对象
17
- | Partial<Plugin>
18
- // 导出工厂函数,接受配置并返回插件对象
19
- | ((config: Record<string, unknown>) => Partial<Plugin>)
20
- ) = module.default ?? module
21
-
22
- if (typeof factory === 'function') {
23
- // TODO: 这里可以注入更多上下文,如全局配置、版本信息等
24
- return factory(config)
25
- } else if (typeof factory === 'object' && factory !== null) {
26
- return factory
27
- }
28
-
29
- console.warn(`Plugin ${name} does not export a valid plugin factory or object.`)
30
- return null
31
- } catch (e) {
32
- console.error(`Failed to load plugin ${name}:`, e)
33
- return null
34
- }
35
- }
36
-
37
- export const resolvePlugins = async (
38
- config: PluginConfig,
39
- enabledPlugins: Record<string, boolean> = {}
40
- ): Promise<Partial<Plugin>[]> => {
41
- // 1. 处理数组形式配置 (直接实例化或函数调用)
42
- if (Array.isArray(config)) {
43
- return config.map((p) => (typeof p === 'function' ? p() : p))
44
- }
45
-
46
- // 2. 处理对象形式配置 (动态加载)
47
- const entries = Object.entries(config)
48
- .filter(([pkgName]) => enabledPlugins[pkgName] !== false)
49
- if (entries.length === 0) return []
50
-
51
- // 并行加载所有插件
52
- const modules = await Promise.allSettled(
53
- entries.map(([pkgName, pkgConfig]) => {
54
- // 如果不是以 @ 或 @vibe-forge/plugin- 开头,则默认加上 @vibe-forge/plugin- 前缀
55
- const resolvedName = pkgName.startsWith('@') ? pkgName : `@vibe-forge/plugin-${pkgName}`
56
- // dprint-ignore
57
- return (
58
- loadPlugin(resolvedName, pkgConfig as Record<string, unknown>) ??
59
- loadPlugin(pkgName, pkgConfig as Record<string, unknown>)
60
- )
61
- })
62
- )
63
-
64
- // 收集成功加载的插件
65
- const plugins: Partial<Plugin>[] = []
66
-
67
- modules.forEach((result, index) => {
68
- const pkgName = entries[index][0]
69
- if (result.status === 'fulfilled') {
70
- if (result.value) {
71
- plugins.push(result.value)
72
- }
73
- } else {
74
- console.error(`Error loading plugin ${pkgName}:`, result.reason)
75
- }
76
- })
77
-
78
- return plugins
79
- }
@@ -1,116 +0,0 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
- import { dirname, resolve } from 'node:path'
3
- import process from 'node:process'
4
-
5
- import type { AdapterCtx } from '../adapter'
6
-
7
- export interface NativeHookHandlerConfig {
8
- type: 'command'
9
- command: string
10
- timeout?: number
11
- statusMessage?: string
12
- }
13
-
14
- export interface NativeHookMatcherGroup {
15
- matcher?: string
16
- hooks?: NativeHookHandlerConfig[]
17
- }
18
-
19
- export interface NativeHooksConfigFile {
20
- hooks?: Partial<Record<string, NativeHookMatcherGroup[]>> & Record<string, unknown>
21
- }
22
-
23
- const isPlainObject = (value: unknown): value is Record<string, unknown> => (
24
- value != null && typeof value === 'object' && !Array.isArray(value)
25
- )
26
-
27
- export const hasManagedHookPlugins = (
28
- ctx: Pick<AdapterCtx, 'assets'>
29
- ) => (ctx.assets?.hookPlugins.length ?? 0) > 0
30
-
31
- export const resolveMockHome = (
32
- cwd: string,
33
- env: Pick<AdapterCtx, 'env'>['env']
34
- ) => {
35
- const explicitHome = env.HOME?.trim() || process.env.HOME?.trim()
36
- return explicitHome ? resolve(explicitHome) : resolve(cwd, '.ai', '.mock')
37
- }
38
-
39
- export const resolveHookCliPackageDir = () => {
40
- try {
41
- const pkgJsonPath = require.resolve('@vibe-forge/cli/package.json')
42
- return dirname(pkgJsonPath)
43
- } catch (error) {
44
- throw new Error('Failed to resolve @vibe-forge/cli for native hooks', { cause: error })
45
- }
46
- }
47
-
48
- export const resolveHookCliScriptPath = (fileName: string) => (
49
- resolve(resolveHookCliPackageDir(), fileName)
50
- )
51
-
52
- export const shellQuote = (value: string) => JSON.stringify(value)
53
-
54
- export const buildNodeScriptCommand = (params: {
55
- nodePath: string
56
- scriptPath: string
57
- }) => `${shellQuote(params.nodePath)} ${shellQuote(params.scriptPath)}`
58
-
59
- export const prepareManagedHookRuntime = (
60
- ctx: Pick<AdapterCtx, 'cwd' | 'env'>
61
- ) => {
62
- const mockHome = resolveMockHome(ctx.cwd, ctx.env)
63
- const cliPackageDir = resolveHookCliPackageDir()
64
- const nodePath = process.execPath
65
-
66
- ctx.env.__VF_PROJECT_CLI_PACKAGE_DIR__ = cliPackageDir
67
- ctx.env.__VF_PROJECT_WORKSPACE_FOLDER__ = ctx.env.__VF_PROJECT_WORKSPACE_FOLDER__ ?? ctx.cwd
68
- ctx.env.__VF_PROJECT_NODE_PATH__ = nodePath
69
-
70
- return {
71
- mockHome,
72
- cliPackageDir,
73
- nodePath
74
- }
75
- }
76
-
77
- export const readJsonFileOrDefault = async <T>(filePath: string, fallback: T): Promise<T> => {
78
- try {
79
- return JSON.parse(await readFile(filePath, 'utf8')) as T
80
- } catch (error) {
81
- if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return fallback
82
- throw error
83
- }
84
- }
85
-
86
- export const writeJsonFile = async (filePath: string, value: unknown) => {
87
- await mkdir(dirname(filePath), { recursive: true })
88
- await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
89
- }
90
-
91
- export const mergeManagedHookGroups = (params: {
92
- existing: unknown
93
- eventNames: readonly string[]
94
- enabled: boolean
95
- isManagedGroup: (group: NativeHookMatcherGroup) => boolean
96
- createGroup: (eventName: string) => NativeHookMatcherGroup
97
- shouldManageEvent?: (eventName: string) => boolean
98
- }): NativeHooksConfigFile => {
99
- const parsed = isPlainObject(params.existing) ? params.existing as NativeHooksConfigFile : {}
100
- const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {}
101
- const nextHooks: Record<string, unknown> = { ...hooks }
102
-
103
- for (const eventName of params.eventNames) {
104
- const groups = Array.isArray(hooks[eventName])
105
- ? (hooks[eventName] as NativeHookMatcherGroup[]).filter(group => !params.isManagedGroup(group))
106
- : []
107
- nextHooks[eventName] = params.enabled && (params.shouldManageEvent?.(eventName) ?? true)
108
- ? [...groups, params.createGroup(eventName)]
109
- : groups
110
- }
111
-
112
- return {
113
- ...parsed,
114
- hooks: nextHooks
115
- }
116
- }