preflight-mcp 0.1.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.
@@ -0,0 +1,311 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export var LogLevel;
4
+ (function (LogLevel) {
5
+ LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG";
6
+ LogLevel[LogLevel["INFO"] = 1] = "INFO";
7
+ LogLevel[LogLevel["WARN"] = 2] = "WARN";
8
+ LogLevel[LogLevel["ERROR"] = 3] = "ERROR";
9
+ LogLevel[LogLevel["FATAL"] = 4] = "FATAL";
10
+ })(LogLevel || (LogLevel = {}));
11
+ export class StructuredLogger {
12
+ config;
13
+ logBuffer = [];
14
+ bufferSize = 1000;
15
+ flushInterval = 5000; // 5秒
16
+ flushTimer;
17
+ constructor(config = {}) {
18
+ this.config = {
19
+ level: LogLevel.INFO,
20
+ output: 'both',
21
+ maxFileSize: 10, // 10MB
22
+ maxFiles: 5,
23
+ enableColors: true,
24
+ enableTimestamp: true,
25
+ enableMetadata: true,
26
+ enableStackTrace: true,
27
+ format: 'text',
28
+ ...config
29
+ };
30
+ this.startFlushTimer();
31
+ }
32
+ startFlushTimer() {
33
+ this.flushTimer = setInterval(() => {
34
+ this.flush().catch(error => {
35
+ console.error('Failed to flush logs:', error);
36
+ });
37
+ }, this.flushInterval);
38
+ // Don't keep the process alive just for log flushing (important for tests/CLI runs).
39
+ this.flushTimer.unref?.();
40
+ }
41
+ async flush() {
42
+ if (this.logBuffer.length === 0) {
43
+ return;
44
+ }
45
+ const entries = [...this.logBuffer];
46
+ this.logBuffer = [];
47
+ if (this.config.output === 'file' || this.config.output === 'both') {
48
+ await this.writeToFile(entries);
49
+ }
50
+ if (this.config.output === 'console' || this.config.output === 'both') {
51
+ this.writeToConsole(entries);
52
+ }
53
+ }
54
+ async writeToFile(entries) {
55
+ if (!this.config.filePath) {
56
+ return;
57
+ }
58
+ try {
59
+ // Ensure log directory exists
60
+ const logDir = path.dirname(this.config.filePath);
61
+ await fs.mkdir(logDir, { recursive: true });
62
+ // Check file size and rotate if over limit
63
+ await this.rotateLogFile();
64
+ // Write log entries
65
+ const logLines = entries.map(entry => this.formatLogEntry(entry, 'json'));
66
+ await fs.appendFile(this.config.filePath, logLines.join('\n') + '\n');
67
+ }
68
+ catch (error) {
69
+ console.error('Failed to write logs to file:', error);
70
+ }
71
+ }
72
+ async rotateLogFile() {
73
+ if (!this.config.filePath) {
74
+ return;
75
+ }
76
+ try {
77
+ const stats = await fs.stat(this.config.filePath);
78
+ const maxSizeBytes = (this.config.maxFileSize || 10) * 1024 * 1024;
79
+ if (stats.size > maxSizeBytes) {
80
+ // Rotate log file
81
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
82
+ const rotatedPath = `${this.config.filePath}.${timestamp}`;
83
+ await fs.rename(this.config.filePath, rotatedPath);
84
+ // Cleanup old log files
85
+ await this.cleanupOldLogFiles();
86
+ }
87
+ }
88
+ catch (error) {
89
+ // File doesn't exist or other error, ignore
90
+ }
91
+ }
92
+ async cleanupOldLogFiles() {
93
+ if (!this.config.filePath) {
94
+ return;
95
+ }
96
+ try {
97
+ const logDir = path.dirname(this.config.filePath);
98
+ const logName = path.basename(this.config.filePath);
99
+ const files = await fs.readdir(logDir);
100
+ // Get file info and sort by mtime
101
+ const logFilesWithMtime = [];
102
+ for (const file of files) {
103
+ if (file.startsWith(logName) && file !== logName) {
104
+ const filePath = path.join(logDir, file);
105
+ try {
106
+ const stats = await fs.stat(filePath);
107
+ logFilesWithMtime.push({
108
+ name: file,
109
+ path: filePath,
110
+ mtime: stats.mtime
111
+ });
112
+ }
113
+ catch {
114
+ // Skip files that can't be stat'd
115
+ }
116
+ }
117
+ }
118
+ // Sort by modification time
119
+ logFilesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
120
+ // Keep the newest files, delete the rest
121
+ const maxFiles = this.config.maxFiles || 5;
122
+ if (logFilesWithMtime.length > maxFiles) {
123
+ const filesToDelete = logFilesWithMtime.slice(maxFiles);
124
+ for (const file of filesToDelete) {
125
+ try {
126
+ await fs.unlink(file.path);
127
+ }
128
+ catch (error) {
129
+ console.error(`Failed to delete old log file ${file.path}:`, error);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ catch (error) {
135
+ console.error('Failed to cleanup old log files:', error);
136
+ }
137
+ }
138
+ writeToConsole(entries) {
139
+ for (const entry of entries) {
140
+ const formatted = this.formatLogEntry(entry, 'text');
141
+ if (this.config.enableColors) {
142
+ this.writeColoredConsole(entry, formatted);
143
+ }
144
+ else {
145
+ // MCP stdio servers must log to stderr to avoid interfering with protocol
146
+ console.error(formatted);
147
+ }
148
+ }
149
+ }
150
+ writeColoredConsole(entry, formatted) {
151
+ const colors = {
152
+ [LogLevel.DEBUG]: '\x1b[36m', // Cyan
153
+ [LogLevel.INFO]: '\x1b[32m', // Green
154
+ [LogLevel.WARN]: '\x1b[33m', // Yellow
155
+ [LogLevel.ERROR]: '\x1b[31m', // Red
156
+ [LogLevel.FATAL]: '\x1b[35m' // Magenta
157
+ };
158
+ const reset = '\x1b[0m';
159
+ const color = colors[entry.level] || '';
160
+ // MCP stdio servers must log to stderr to avoid interfering with protocol
161
+ console.error(`${color}${formatted}${reset}`);
162
+ }
163
+ formatLogEntry(entry, format) {
164
+ if (format === 'json') {
165
+ return JSON.stringify(entry);
166
+ }
167
+ const parts = [];
168
+ // Timestamp
169
+ if (this.config.enableTimestamp) {
170
+ parts.push(`[${entry.timestamp}]`);
171
+ }
172
+ // Log level
173
+ parts.push(`[${entry.levelName}]`);
174
+ // Module and function info
175
+ if (entry.module || entry.function) {
176
+ const location = [entry.module, entry.function].filter(Boolean).join('.');
177
+ parts.push(`[${location}]`);
178
+ }
179
+ // Main message
180
+ parts.push(entry.message);
181
+ // Metadata
182
+ if (entry.metadata && Object.keys(entry.metadata).length > 0) {
183
+ parts.push(`| ${JSON.stringify(entry.metadata)}`);
184
+ }
185
+ // Error info
186
+ if (entry.error && this.config.enableStackTrace && entry.error.stack) {
187
+ parts.push(`\n${entry.error.stack}`);
188
+ }
189
+ return parts.join(' ');
190
+ }
191
+ createLogEntry(level, message, metadata, error) {
192
+ const stack = new Error().stack;
193
+ const callerLine = stack?.split('\n')[3]; // 获取调用栈的第3行
194
+ let module;
195
+ let func;
196
+ let line;
197
+ if (callerLine) {
198
+ const match = callerLine.match(/at\s+(.+?)\s+\((.+?):(\d+):\d+\)/);
199
+ if (match && match[1] && match[2] && match[3]) {
200
+ func = match[1];
201
+ const filePath = match[2];
202
+ line = parseInt(match[3], 10);
203
+ module = path.basename(filePath, '.js');
204
+ }
205
+ }
206
+ return {
207
+ timestamp: new Date().toISOString(),
208
+ level,
209
+ levelName: LogLevel[level],
210
+ message,
211
+ module,
212
+ function: func,
213
+ line,
214
+ metadata,
215
+ error: error ? {
216
+ name: error.name,
217
+ message: error.message,
218
+ stack: error.stack
219
+ } : undefined
220
+ };
221
+ }
222
+ log(level, message, metadata, error) {
223
+ if (level < this.config.level) {
224
+ return;
225
+ }
226
+ const entry = this.createLogEntry(level, message, metadata, error);
227
+ if (this.config.output === 'console' || this.config.output === 'both') {
228
+ // For console output, write immediately
229
+ this.writeToConsole([entry]);
230
+ }
231
+ if (this.config.output === 'file' || this.config.output === 'both') {
232
+ // For file output, buffer and batch write
233
+ this.logBuffer.push(entry);
234
+ if (this.logBuffer.length >= this.bufferSize) {
235
+ this.flush().catch(error => {
236
+ console.error('Failed to flush logs:', error);
237
+ });
238
+ }
239
+ }
240
+ }
241
+ debug(message, metadata) {
242
+ this.log(LogLevel.DEBUG, message, metadata);
243
+ }
244
+ info(message, metadata) {
245
+ this.log(LogLevel.INFO, message, metadata);
246
+ }
247
+ warn(message, metadata) {
248
+ this.log(LogLevel.WARN, message, metadata);
249
+ }
250
+ error(message, error, metadata) {
251
+ this.log(LogLevel.ERROR, message, metadata, error);
252
+ }
253
+ fatal(message, error, metadata) {
254
+ this.log(LogLevel.FATAL, message, metadata, error);
255
+ }
256
+ // Flush buffer immediately
257
+ async flushNow() {
258
+ await this.flush();
259
+ }
260
+ // Update configuration
261
+ updateConfig(config) {
262
+ this.config = { ...this.config, ...config };
263
+ }
264
+ // Get current configuration
265
+ getConfig() {
266
+ return { ...this.config };
267
+ }
268
+ // Close the logger
269
+ async close() {
270
+ if (this.flushTimer) {
271
+ clearInterval(this.flushTimer);
272
+ }
273
+ await this.flush();
274
+ }
275
+ }
276
+ // Default logger instance
277
+ export const defaultLogger = new StructuredLogger({
278
+ level: LogLevel.INFO,
279
+ output: 'both',
280
+ filePath: './logs/preflight-mcp.log',
281
+ format: 'text'
282
+ });
283
+ // Convenience functions
284
+ export const logger = {
285
+ debug: (message, metadata) => defaultLogger.debug(message, metadata),
286
+ info: (message, metadata) => defaultLogger.info(message, metadata),
287
+ warn: (message, metadata) => defaultLogger.warn(message, metadata),
288
+ error: (message, error, metadata) => defaultLogger.error(message, error, metadata),
289
+ fatal: (message, error, metadata) => defaultLogger.fatal(message, error, metadata),
290
+ flush: () => defaultLogger.flushNow(),
291
+ updateConfig: (config) => defaultLogger.updateConfig(config),
292
+ getConfig: () => defaultLogger.getConfig(),
293
+ close: () => defaultLogger.close()
294
+ };
295
+ // Create a module-specific logger
296
+ export function createModuleLogger(moduleName, config) {
297
+ const moduleConfig = {
298
+ ...config,
299
+ // Module-specific configuration can be added here
300
+ };
301
+ const moduleLogger = new StructuredLogger(moduleConfig);
302
+ return {
303
+ debug: (message, metadata) => moduleLogger.debug(message, { module: moduleName, ...metadata }),
304
+ info: (message, metadata) => moduleLogger.info(message, { module: moduleName, ...metadata }),
305
+ warn: (message, metadata) => moduleLogger.warn(message, { module: moduleName, ...metadata }),
306
+ error: (message, error, metadata) => moduleLogger.error(message, error, { module: moduleName, ...metadata }),
307
+ fatal: (message, error, metadata) => moduleLogger.fatal(message, error, { module: moduleName, ...metadata }),
308
+ flush: () => moduleLogger.flushNow(),
309
+ close: () => moduleLogger.close()
310
+ };
311
+ }
@@ -0,0 +1,45 @@
1
+ import path from 'node:path';
2
+ export const PREFLIGHT_URI_PREFIX = 'preflight://bundle/';
3
+ export function toBundleFileUri(ref) {
4
+ // encodedPath must not contain slashes so it can live as a single path segment
5
+ const encodedPath = encodeURIComponent(ref.relativePath);
6
+ return `${PREFLIGHT_URI_PREFIX}${ref.bundleId}/file/${encodedPath}`;
7
+ }
8
+ export function parseBundleFileUri(uri) {
9
+ if (!uri.startsWith(PREFLIGHT_URI_PREFIX))
10
+ return null;
11
+ const rest = uri.slice(PREFLIGHT_URI_PREFIX.length);
12
+ // rest = <bundleId>/file/<encodedPath>
13
+ const parts = rest.split('/');
14
+ if (parts.length !== 3)
15
+ return null;
16
+ const [bundleId, fileLiteral, encodedPath] = parts;
17
+ if (!bundleId || fileLiteral !== 'file' || !encodedPath)
18
+ return null;
19
+ const relativePath = decodeURIComponent(encodedPath);
20
+ return {
21
+ bundleId,
22
+ relativePath,
23
+ };
24
+ }
25
+ export function normalizeRelativePath(p) {
26
+ // Ensure forward slashes and no leading slash.
27
+ return p.replaceAll('\\', '/').replace(/^\/+/, '');
28
+ }
29
+ export function safeJoin(rootDir, relativePath) {
30
+ // Block absolute paths BEFORE normalization.
31
+ // This catches Unix-style /etc/passwd and Windows-style C:\path.
32
+ const trimmed = relativePath.trim();
33
+ if (trimmed.startsWith('/') || trimmed.startsWith('\\') || /^[a-zA-Z]:/.test(trimmed)) {
34
+ throw new Error('Unsafe path traversal attempt');
35
+ }
36
+ // Convert to platform separator for join, but validate containment by resolving.
37
+ const norm = normalizeRelativePath(relativePath);
38
+ const joined = path.resolve(rootDir, norm.split('/').join(path.sep));
39
+ const rootResolved = path.resolve(rootDir);
40
+ const rel = path.relative(rootResolved, joined);
41
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
42
+ throw new Error('Unsafe path traversal attempt');
43
+ }
44
+ return joined;
45
+ }