@chen-rmag/core-infra 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +46 -0
  2. package/dist/ProjectContextManager.d.ts +30 -0
  3. package/dist/ProjectContextManager.js +41 -0
  4. package/dist/directory-validator.d.ts +28 -0
  5. package/dist/directory-validator.js +90 -0
  6. package/dist/index.d.ts +14 -0
  7. package/dist/index.js +44 -0
  8. package/dist/mcp/file-mcp-manager.d.ts +13 -0
  9. package/dist/mcp/file-mcp-manager.js +45 -0
  10. package/dist/mcp/index.d.ts +20 -0
  11. package/dist/mcp/index.js +16 -0
  12. package/dist/mcp/mcp-client.d.ts +127 -0
  13. package/dist/mcp/mcp-client.js +165 -0
  14. package/dist/mcp/mcp-manager.d.ts +20 -0
  15. package/dist/mcp/mcp-manager.js +98 -0
  16. package/dist/mcp/playwright-mcp-manager.d.ts +18 -0
  17. package/dist/mcp/playwright-mcp-manager.js +115 -0
  18. package/dist/model.d.ts +10 -0
  19. package/dist/model.js +207 -0
  20. package/dist/repositories/BaseRepository.d.ts +68 -0
  21. package/dist/repositories/BaseRepository.js +212 -0
  22. package/dist/repositories/DirectoryRepository.d.ts +69 -0
  23. package/dist/repositories/DirectoryRepository.js +335 -0
  24. package/dist/repositories/ExplorationRepository.d.ts +33 -0
  25. package/dist/repositories/ExplorationRepository.js +53 -0
  26. package/dist/repositories/FileRepository.d.ts +55 -0
  27. package/dist/repositories/FileRepository.js +131 -0
  28. package/dist/repositories/ModelConfigRepository.d.ts +33 -0
  29. package/dist/repositories/ModelConfigRepository.js +51 -0
  30. package/dist/repositories/ProjectRepository.d.ts +31 -0
  31. package/dist/repositories/ProjectRepository.js +66 -0
  32. package/dist/repositories/SettingsRepository.d.ts +18 -0
  33. package/dist/repositories/SettingsRepository.js +71 -0
  34. package/dist/repositories/TableDataRepository.d.ts +21 -0
  35. package/dist/repositories/TableDataRepository.js +32 -0
  36. package/dist/repositories/TestCaseRepository.d.ts +120 -0
  37. package/dist/repositories/TestCaseRepository.js +463 -0
  38. package/dist/repositories/TestPlanRepository.d.ts +34 -0
  39. package/dist/repositories/TestPlanRepository.js +79 -0
  40. package/dist/repositories/TestResultRepository.d.ts +29 -0
  41. package/dist/repositories/TestResultRepository.js +53 -0
  42. package/dist/repositories/index.d.ts +16 -0
  43. package/dist/repositories/index.js +30 -0
  44. package/dist/storageService.d.ts +129 -0
  45. package/dist/storageService.js +297 -0
  46. package/dist/types.d.ts +217 -0
  47. package/dist/types.js +2 -0
  48. package/package.json +32 -0
  49. package/src/directory-validator.ts +98 -0
  50. package/src/index.ts +26 -0
  51. package/src/mcp/file-mcp-manager.ts +50 -0
  52. package/src/mcp/index.ts +35 -0
  53. package/src/mcp/mcp-client.ts +209 -0
  54. package/src/mcp/mcp-manager.ts +118 -0
  55. package/src/mcp/playwright-mcp-manager.ts +127 -0
  56. package/src/model.ts +234 -0
  57. package/src/repositories/BaseRepository.ts +193 -0
  58. package/src/repositories/DirectoryRepository.ts +393 -0
  59. package/src/repositories/ExplorationRepository.ts +57 -0
  60. package/src/repositories/FileRepository.ts +153 -0
  61. package/src/repositories/ModelConfigRepository.ts +55 -0
  62. package/src/repositories/ProjectRepository.ts +70 -0
  63. package/src/repositories/SettingsRepository.ts +38 -0
  64. package/src/repositories/TableDataRepository.ts +33 -0
  65. package/src/repositories/TestCaseRepository.ts +521 -0
  66. package/src/repositories/TestPlanRepository.ts +89 -0
  67. package/src/repositories/TestResultRepository.ts +56 -0
  68. package/src/repositories/index.ts +17 -0
  69. package/src/storageService.ts +404 -0
  70. package/src/types.ts +246 -0
  71. package/tsconfig.json +19 -0
@@ -0,0 +1,118 @@
1
+ import { MCPClient, MCPTool } from '@ai-test/core-infra/src/mcp/mcp-client';
2
+ import { FileMCPManager } from '@ai-test/core-infra/src/mcp/file-mcp-manager';
3
+ import { PlaywrightMCPManager } from '@ai-test/core-infra/src/mcp/playwright-mcp-manager';
4
+ import { GroupedMCPManager } from '@ai-test/core-infra/src/mcp';
5
+
6
+ export interface ToolGroupInfo {
7
+ groupId: string;
8
+ description: string;
9
+ }
10
+
11
+ export class MCPManager {
12
+ private managers: GroupedMCPManager[] = [];
13
+ private connected: boolean = false;
14
+ private language: string = 'zh-CN';
15
+
16
+ getManagers(): GroupedMCPManager[] {
17
+ return this.managers;
18
+ }
19
+
20
+ getClient(groupName: string): MCPClient {
21
+ for (const manager of this.managers) {
22
+ if (manager.getToolGroupInfo()[groupName]) {
23
+ return manager.getClient();
24
+ }
25
+ }
26
+
27
+ throw new Error(`MCP Client for group ${groupName} is not found`);
28
+ }
29
+
30
+ async start(language: string, forced: boolean = false): Promise<void> {
31
+ const playwrightClient = new PlaywrightMCPManager({ language: language });
32
+ const fileClient = new FileMCPManager();
33
+ this.managers = [playwrightClient, fileClient];
34
+ if (language !== this.language) {
35
+ forced = true;
36
+ }
37
+ this.language = language;
38
+
39
+ await Promise.all(this.managers.map(manager => manager.start(forced)));
40
+ this.connected = true;
41
+ }
42
+
43
+ async stop() {
44
+ for (const manager of this.managers) {
45
+ try {
46
+ await manager.getClient().disconnect();
47
+ } catch (error) {
48
+ console.error(`Error disconnecting MCP Client:`, error);
49
+ }
50
+ }
51
+ this.connected = false;
52
+ }
53
+
54
+ isConnected(): boolean {
55
+ return this.connected;
56
+ }
57
+
58
+ getToolGroupInfo(): ToolGroupInfo[] {
59
+ return this.managers.flatMap((manager) =>
60
+ manager.groups
61
+ .filter((group) => !group.loadByDefault)
62
+ .map((group) => ({
63
+ groupId: group.name,
64
+ description: group.description,
65
+ }))
66
+ );
67
+ }
68
+
69
+ getAllAvailableTools(): MCPTool[] {
70
+ let allTools: MCPTool[] = [];
71
+ for (const manager of this.managers) {
72
+ const tools = manager.getClient().getAvailableTools().filter(tool => {
73
+ return manager.groups.some(group => !group.tools || group.tools.includes(tool.name));
74
+ });
75
+ allTools = allTools.concat(tools);
76
+ }
77
+
78
+ return allTools;
79
+ }
80
+
81
+ getAvailableTools(groupName: string): MCPTool[] {
82
+ for (const manager of this.managers) {
83
+ const group = manager.groups.find(g => g.name === groupName);
84
+ if (group) {
85
+ const toolNames = group.tools;
86
+ const all = manager.getClient().getAvailableTools();
87
+ if (!toolNames) {
88
+ return all;
89
+ }
90
+
91
+ return toolNames.filter(name => all.some(tool => tool.name === name)).map(name => all.find(tool => tool.name === name)!);
92
+ }
93
+ }
94
+
95
+ throw new Error(`Tool group ${groupName} not found`);
96
+ }
97
+
98
+
99
+ getDefaultTools(): MCPTool[] {
100
+ const allTools: MCPTool[] = [];
101
+ for (const manager of this.managers) {
102
+ for (const group of manager.groups) {
103
+ if (!group.loadByDefault || !group.tools) {
104
+ continue;
105
+ }
106
+
107
+ const tools = manager.getClient().getAvailableTools();
108
+ for (const tool of tools) {
109
+ if (group.tools.includes(tool.name)) {
110
+ allTools.push(tool);
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ return allTools;
117
+ }
118
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * MCP (Model Context Protocol) Client for Playwright integration
3
+ * Integrates with https://github.com/microsoft/playwright-mcp
4
+ */
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+ import { MCPClient, MCPConfig } from './mcp-client';
9
+ import { AbstractGroupedMCPManager } from '.';
10
+
11
+ export interface PlaywrightMCPOpts {
12
+ language: string,
13
+ headless?: boolean,
14
+ }
15
+
16
+ export class PlaywrightMCPManager extends AbstractGroupedMCPManager {
17
+ private playwrightConfigPath?: string;
18
+ private playwrightConfigDir?: string;
19
+ private opts: PlaywrightMCPOpts;
20
+ private client?: MCPClient;
21
+
22
+ constructor(opts: PlaywrightMCPOpts) {
23
+ super([{
24
+ name: 'playwright_basic',
25
+ description: 'Basic Playwright tools for browser interactions',
26
+ tools: ['browser_navigate', 'browser_click', 'browser_fill_form', 'browser_press_key', 'browser_run_code', 'browser_select_option', 'browser_snapshot', 'browser_type', 'browser_wait_for'],
27
+ loadByDefault: true,
28
+ },
29
+ {
30
+ name: 'playwright_browser_manage',
31
+ description: `Playwright tools for browser and tab management, including: browser window resize, go back to previous page, close a page, list/create/close/select a tab`,
32
+ tools: ['browser_resize', 'browser_navigate_back', 'browser_close', 'browser_tabs'],
33
+ loadByDefault: false,
34
+ },
35
+ {
36
+ name: 'playwright_other',
37
+ description: 'Other Playwright tools for browser interactions, including: mouse hover on element, drag and drop an element, upload file, handle dialog, evaluate JavaScript on page or element',
38
+ tools: ['browser_hover', 'browser_drag', 'browser_file_upload', 'browser_handle_dialog', 'browser_evaluate'],
39
+ loadByDefault: false,
40
+ }
41
+ ]);
42
+ this.opts = opts;
43
+ }
44
+
45
+ async start(forced: boolean = false) {
46
+ this.prepareTempPlaywrightConfig(this.opts.language);
47
+ const client = this.createClient(this.opts);
48
+ this.client = client;
49
+ try {
50
+ await client.connect(forced);
51
+ } finally {
52
+ this.cleanupTempPlaywrightConfig();
53
+ }
54
+ }
55
+
56
+ getClient(): MCPClient {
57
+ if (!this.client) {
58
+ throw new Error("MCP Client is not started yet");
59
+ }
60
+ return this.client;
61
+ }
62
+
63
+ private createClient(opts: PlaywrightMCPOpts): MCPClient {
64
+ // Build args with headless mode
65
+ const args = [
66
+ '@cotestdev/mcp_playwright@latest',
67
+ '--image-responses', 'omit',
68
+ '--caps',
69
+ 'core,core-tabs,extra'
70
+ ];
71
+
72
+ if (this.playwrightConfigPath) {
73
+ args.push('--config', this.playwrightConfigPath);
74
+ }
75
+
76
+ // Explicitly set headless or headed mode
77
+ // Playwright MCP requires explicit declaration of the mode
78
+ if (opts.headless) {
79
+ args.push('--headless');
80
+ console.log('Browser will run in HEADLESS mode (background)');
81
+ } else {
82
+ console.log('Browser will run in HEADED mode (visible window)');
83
+ }
84
+
85
+ const mcpConfig: MCPConfig = {
86
+ cmd: 'npx',
87
+ args: args,
88
+ env: { ...process.env } as Record<string, string>,
89
+ }
90
+
91
+ return new MCPClient(mcpConfig);
92
+ }
93
+
94
+ private prepareTempPlaywrightConfig(language: string) {
95
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'playwright-mcp-'));
96
+ const configPath = path.join(tempDir, 'mcp.config.json');
97
+
98
+ const configContents = `
99
+ {
100
+ "browser": {
101
+ "contextOptions": {
102
+ "locale": "${language}"
103
+ }
104
+ }
105
+ }
106
+ `;
107
+
108
+ fs.writeFileSync(configPath, configContents, 'utf8');
109
+
110
+ this.playwrightConfigDir = tempDir;
111
+ this.playwrightConfigPath = configPath;
112
+ console.log('Temporary Playwright config created at', configPath);
113
+ }
114
+
115
+ private cleanupTempPlaywrightConfig(): void {
116
+ if (!this.playwrightConfigDir) return;
117
+
118
+ try {
119
+ fs.rmSync(this.playwrightConfigDir, { recursive: true, force: true });
120
+ } catch (error) {
121
+ console.error('Failed to remove temporary Playwright config directory:', error);
122
+ } finally {
123
+ this.playwrightConfigDir = undefined;
124
+ this.playwrightConfigPath = undefined;
125
+ }
126
+ }
127
+ }
package/src/model.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { ModelConfig } from "./types";
2
+ import { BaseLanguageModel } from "@langchain/core/language_models/base";
3
+ import { ChatOpenAI } from '@langchain/openai';
4
+ import { ChatAnthropic } from '@langchain/anthropic';
5
+ import { ChatMistralAI } from '@langchain/mistralai';
6
+ import { DynamicStructuredTool } from '@langchain/core/tools';
7
+ import { BaseChatModel } from "@langchain/core/language_models/chat_models";
8
+ import z from "zod";
9
+ import { MCPManager } from "./mcp/mcp-manager";
10
+
11
+ export function createModel(
12
+ config: ModelConfig,
13
+ bindTools: boolean = false,
14
+ mcpManager?: MCPManager,
15
+ useDefaultFuncs: boolean = false,
16
+ ): BaseLanguageModel {
17
+ let model: BaseLanguageModel;
18
+
19
+ if (config.provider === 'openai') {
20
+ const openaiConfig = {
21
+ apiKey: config.apiKey,
22
+ model: config.modelName || 'gpt-4-turbo',
23
+ ...config,
24
+ };
25
+
26
+ if (config.baseURL) {
27
+ (openaiConfig as Record<string, unknown>).configuration = {
28
+ baseURL: config.baseURL,
29
+ };
30
+ }
31
+
32
+ model = new ChatOpenAI(openaiConfig as Record<string, unknown>);
33
+ } else if (config.provider === 'anthropic') {
34
+ const anthropicConfig = {
35
+ apiKey: config.apiKey,
36
+ model: config.modelName || 'claude-3-sonnet-20240229',
37
+ anthropicApiUrl: config.baseURL,
38
+ clientOptions: {
39
+ baseURL: config.baseURL,
40
+ apiKey: config.apiKey,
41
+ },
42
+ ...config,
43
+ };
44
+
45
+ model = new ChatAnthropic(anthropicConfig as Record<string, unknown>);
46
+ } else if (config.provider === 'mistral') {
47
+ // Mistral uses ChatMistralAI package
48
+ const mistralConfig = {
49
+ apiKey: config.apiKey,
50
+ model: config.modelName || 'mistral-large-latest',
51
+ ...config,
52
+ };
53
+
54
+ model = new ChatMistralAI(mistralConfig as Record<string, unknown>);
55
+ } else if (config.provider === 'openrouter') {
56
+ // OpenRouter uses OpenAI-compatible API
57
+ const openrouterBaseURL = config.baseURL;
58
+ const openrouterConfig = {
59
+ apiKey: config.apiKey,
60
+ model: config.modelName,
61
+ configuration: { baseURL: openrouterBaseURL },
62
+ ...config,
63
+ };
64
+
65
+ model = new ChatOpenAI(openrouterConfig as Record<string, unknown>);
66
+ } else if (config.provider === 'ollama') {
67
+ const baseURL = config.baseURL || 'http://localhost:11434';
68
+ model = new ChatOpenAI({
69
+ apiKey: 'ollama',
70
+ model: config.modelName || 'llama2',
71
+ configuration: { baseURL },
72
+ ...config,
73
+ } as Record<string, unknown>);
74
+ } else {
75
+ throw new Error(`Unknown model provider: ${config.provider}`);
76
+ }
77
+
78
+ if (bindTools && mcpManager) {
79
+ model = bindPlaywrightTools(model, mcpManager, useDefaultFuncs);
80
+ }
81
+
82
+ return model;
83
+ }
84
+
85
+ /**
86
+ * Bind Playwright MCP tools to the model
87
+ * Creates LangChain tools from MCP tool definitions
88
+ */
89
+ function bindPlaywrightTools(model: BaseLanguageModel, mcpManager: MCPManager, useDefaultFuncs: boolean): BaseLanguageModel {
90
+ const defaultTools = mcpManager.getDefaultTools();
91
+ // Convert MCP tools to LangChain tools (ensure each tool has its own JSON-schema-as-Zod schema)
92
+ const langchainTools: DynamicStructuredTool[] = defaultTools.map(mcpTool => {
93
+ const schema = convertToZodSchema(mcpTool.inputSchema || {});
94
+ return new DynamicStructuredTool({
95
+ name: mcpTool.name,
96
+ description: mcpTool.description,
97
+ schema,
98
+ func: async (input, runner, config) => {
99
+ const abortSignal = config?.signal ?? new AbortController().signal;
100
+ for (const manager of mcpManager.getManagers()) {
101
+ const tool = manager.getClient().getToolByName(mcpTool.name);
102
+ if (tool) {
103
+ return manager.getClient().doCallTool(mcpTool.name, input as Record<string, unknown>, abortSignal);
104
+ }
105
+ }
106
+ },
107
+ });
108
+ });
109
+
110
+ if (useDefaultFuncs) {
111
+ const finishTool = {
112
+ name: "finish_test",
113
+ description: "Call this tool ONLY when the test goal has been fully achieved or failed to achieve. After calling this, no more tools should be called.",
114
+ schema: z.object({
115
+ final_summary: z.optional(z.string()).describe("A brief summary confirming the test goal was achieved."),
116
+ }),
117
+ func: async () => { },
118
+ }
119
+ const getToolGroupsTool = {
120
+ name: "get_tools",
121
+ description: "Get available tools in a group.",
122
+ schema: z.object({
123
+ group_name: z.string().describe("The name of the tool group."),
124
+ }),
125
+ func: async () => {
126
+ },
127
+ }
128
+ const callToolTool = {
129
+ name: "call_tool",
130
+ description: "Call a tool in a group by name.",
131
+ schema: z.object({
132
+ group_name: z.string().describe("Tool group name."),
133
+ tool_name: z.string().describe("Tool name."),
134
+ args: z.object().describe("Tool parameters."),
135
+ }),
136
+ func: async () => {
137
+ },
138
+ }
139
+ const reuseTestTool = {
140
+ name: "reuse_test",
141
+ description: "Reuse a test from the provided reusable tests.",
142
+ schema: z.object({
143
+ id: z.string().describe("ID of the reusable test."),
144
+ args: z.object().describe("Reusable test"),
145
+ }),
146
+ func: async () => {
147
+ },
148
+ }
149
+ langchainTools.push(new DynamicStructuredTool(finishTool), new DynamicStructuredTool(callToolTool), new DynamicStructuredTool(getToolGroupsTool), new DynamicStructuredTool(reuseTestTool));
150
+ }
151
+
152
+ // For models that support tool binding (OpenAI, Anthropic), bind the tools
153
+ // Note: Not all models support bindTools(), so we skip it if not available
154
+ const modelAny = model as BaseChatModel;
155
+ if (modelAny && typeof modelAny.bindTools === 'function') {
156
+ // Force the model to use tools with tool_choice: 'required'
157
+ // This ensures the model will always invoke a tool rather than responding directly
158
+ // Only OpenAI and Anthropic fully support this parameter
159
+
160
+ return modelAny.bindTools(langchainTools, {
161
+ tool_choice: 'required',
162
+ }) as BaseLanguageModel;
163
+ }
164
+
165
+ return model;
166
+ }
167
+
168
+
169
+ /**
170
+ * Convert a plain object schema to a Zod schema
171
+ * Handles conversion of MCP tool input schemas to LangChain-compatible Zod types
172
+ */
173
+ export function convertToZodSchema(
174
+ schema: Record<string, unknown>
175
+ ): z.ZodType {
176
+ // If schema is already a Zod type, return it
177
+ if (schema instanceof z.ZodType) {
178
+ return schema;
179
+ }
180
+
181
+ // Handle JSON Schema format (common from MCP tools)
182
+ if (schema.type === 'object' && schema.properties) {
183
+ const properties: Record<string, z.ZodTypeAny> = {};
184
+
185
+ const props = schema.properties as Record<string, unknown>;
186
+ const required = (schema.required as string[]) || [];
187
+
188
+ for (const [key, value] of Object.entries(props)) {
189
+ const propSchema = value as Record<string, unknown>;
190
+ const zodType = convertPropertyToZod(propSchema);
191
+
192
+ if (required.includes(key)) {
193
+ properties[key] = zodType;
194
+ } else {
195
+ properties[key] = zodType.optional();
196
+ }
197
+ }
198
+
199
+ return z.object(properties);
200
+ }
201
+
202
+ /**
203
+ * Convert a single property schema to Zod type
204
+ */
205
+ function convertPropertyToZod(schema: Record<string, unknown>): z.ZodTypeAny {
206
+ const type = schema.type as string;
207
+
208
+ switch (type) {
209
+ case 'string':
210
+ return z.string().describe((schema.description as string) || '');
211
+
212
+ case 'number':
213
+ case 'integer':
214
+ return z.number().describe((schema.description as string) || '');
215
+
216
+ case 'boolean':
217
+ return z.boolean().describe((schema.description as string) || '');
218
+
219
+ case 'array':
220
+ const items = schema.items as Record<string, unknown>;
221
+ const itemType = items ? convertPropertyToZod(items) : z.any();
222
+ return z.array(itemType).describe((schema.description as string) || '');
223
+
224
+ case 'object':
225
+ return z.object({}).catchall(z.any()).describe((schema.description as string) || '');
226
+
227
+ default:
228
+ return z.any();
229
+ }
230
+ }
231
+
232
+ // Fallback: accept any object with catchall
233
+ return z.object({}).catchall(z.any());
234
+ }
@@ -0,0 +1,193 @@
1
+ /* eslint-disable @typescript-eslint/no-unused-vars */
2
+ import { homedir } from 'os';
3
+ import { writeFile, readFile, readdir, rm } from 'fs/promises';
4
+ import path, { join } from 'path';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
6
+
7
+ /**
8
+ * Base repository class providing common file I/O operations
9
+ * Implements the base functionality for all data repositories
10
+ *
11
+ * Supports project-isolated data storage:
12
+ * - When projectId is active: ~/.test_agent/projects/{projectId}/{subDir}/
13
+ * - When no project: ~/.test_agent/{subDir}/
14
+ */
15
+ export abstract class BaseRepository {
16
+ protected storageDir: string;
17
+ protected subDir: string;
18
+ protected explicitProjectId?: string;
19
+
20
+ constructor(subDir: string) {
21
+ this.storageDir = join(homedir(), '.test_agent');
22
+ this.subDir = subDir;
23
+ this.ensureDir();
24
+ }
25
+
26
+ /**
27
+ * Ensure the subdirectory exists
28
+ */
29
+ protected ensureDir(): void {
30
+ const dirPath = this.getBaseDir();
31
+ if (!existsSync(dirPath)) {
32
+ mkdirSync(dirPath, { recursive: true });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Get the base directory for this repository
38
+ * When a project is active:
39
+ * - For 'projects': returns ~/.test_agent/projects (projects.json location)
40
+ * - For other repos: returns ~/.test_agent/projects/{projectId}/{subDir}
41
+ * When no project:
42
+ * - returns ~/.test_agent/{subDir}
43
+ */
44
+ protected getBaseDir(): string {
45
+ // Global directory: ~/.test_agent/{subDir}
46
+ return join(this.storageDir, this.subDir);
47
+ }
48
+
49
+ /**
50
+ * Get full file path for a specific ID
51
+ */
52
+ protected getFilePath(id: string, extension: string = 'json'): string {
53
+ return join(this.getBaseDir(), `${id}.${extension}`);
54
+ }
55
+
56
+ /**
57
+ * Save data to file (synchronous)
58
+ */
59
+ protected saveSync(id: string, data: unknown, extension: string = 'json'): void {
60
+ const filePath = this.getFilePath(id, extension);
61
+ try {
62
+ this.createDirectoriesRecursively(filePath);
63
+ } catch (error) {
64
+ }
65
+ writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
66
+ }
67
+
68
+ private createDirectoriesRecursively(targetPath: string): void {
69
+ // If target path is a file path, get its parent directory; otherwise, use target path as directory
70
+ const dir = path.extname(targetPath) ? path.dirname(targetPath) : targetPath;
71
+
72
+ if (!existsSync(dir)) {
73
+ mkdirSync(dir, { recursive: true });
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Save data to file (asynchronous)
79
+ */
80
+ protected async save(id: string, data: unknown, extension: string = 'json'): Promise<void> {
81
+ const filePath = this.getFilePath(id, extension);
82
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
83
+ }
84
+
85
+ /**
86
+ * Load data from file (synchronous)
87
+ */
88
+ protected loadSync<T>(id: string, extension: string = 'json'): T | null {
89
+ const filePath = this.getFilePath(id, extension);
90
+ if (!existsSync(filePath)) {
91
+ return null;
92
+ }
93
+ const content = readFileSync(filePath, 'utf-8');
94
+ return JSON.parse(content) as T;
95
+ }
96
+
97
+ /**
98
+ * Load data from file (asynchronous)
99
+ */
100
+ protected async load<T>(id: string, extension: string = 'json'): Promise<T | null> {
101
+ const filePath = this.getFilePath(id, extension);
102
+ try {
103
+ const content = await readFile(filePath, 'utf-8');
104
+ return JSON.parse(content) as T;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * List all files in the directory
112
+ */
113
+ protected async listAll<T>(filter?: (filename: string) => boolean): Promise<T[]> {
114
+ const dirPath = this.getBaseDir();
115
+ try {
116
+ const files = await readdir(dirPath);
117
+ const results: T[] = [];
118
+
119
+ for (const file of files) {
120
+ if (filter && !filter(file)) {
121
+ continue;
122
+ }
123
+ if (!file.endsWith('.json')) {
124
+ continue;
125
+ }
126
+ const filePath = join(dirPath, file);
127
+ const content = await readFile(filePath, 'utf-8');
128
+ results.push(JSON.parse(content) as T);
129
+ }
130
+
131
+ return results;
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ /**
138
+ * List all files synchronously
139
+ */
140
+ protected listAllSync<T>(filter?: (filename: string) => boolean): T[] {
141
+ const dirPath = this.getBaseDir();
142
+ try {
143
+ const files = readdirSync(dirPath);
144
+ const results: T[] = [];
145
+
146
+ for (const file of files) {
147
+ if (filter && !filter(file)) {
148
+ continue;
149
+ }
150
+ if (!file.endsWith('.json')) {
151
+ continue;
152
+ }
153
+ const filePath = join(dirPath, file);
154
+ const content = readFileSync(filePath, 'utf-8');
155
+ results.push(JSON.parse(content) as T);
156
+ }
157
+
158
+ return results;
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Delete a file (synchronous)
166
+ */
167
+ protected deleteSync(id: string, extension: string = 'json'): void {
168
+ const filePath = this.getFilePath(id, extension);
169
+ if (existsSync(filePath)) {
170
+ rmSync(filePath);
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Delete a file (asynchronous)
176
+ */
177
+ protected async delete(id: string, extension: string = 'json'): Promise<void> {
178
+ const filePath = this.getFilePath(id, extension);
179
+ try {
180
+ await rm(filePath);
181
+ } catch {
182
+ // File doesn't exist, ignore error
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Check if file exists
188
+ */
189
+ protected existsSync(id: string, extension: string = 'json'): boolean {
190
+ const filePath = this.getFilePath(id, extension);
191
+ return existsSync(filePath);
192
+ }
193
+ }