@ebowwa/claude-code-config-mcp 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.
- package/.claude/CLAUDE.md +3 -0
- package/README.md +237 -0
- package/bun.lock +206 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1744 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +197 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +51 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/errors.d.ts +63 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +156 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/file.d.ts +32 -0
- package/dist/utils/file.d.ts.map +1 -0
- package/dist/utils/file.js +146 -0
- package/dist/utils/file.js.map +1 -0
- package/dist/utils/path.d.ts +59 -0
- package/dist/utils/path.d.ts.map +1 -0
- package/dist/utils/path.js +146 -0
- package/dist/utils/path.js.map +1 -0
- package/lmdb.db +0 -0
- package/lmdb.db-lock +0 -0
- package/package.json +43 -0
- package/src/index.js +2171 -0
- package/src/index.ts +1981 -0
- package/src/types.js +53 -0
- package/src/types.ts +237 -0
- package/src/utils/errors.js +231 -0
- package/src/utils/errors.ts +210 -0
- package/src/utils/file.js +251 -0
- package/src/utils/file.ts +174 -0
- package/src/utils/path.js +169 -0
- package/src/utils/path.ts +173 -0
- package/test/test.js +136 -0
- package/test/test.ts +79 -0
- package/test/write-test.js +153 -0
- package/test/write-test.ts +102 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Error Handling - Categorization, retry logic, and user-friendly messages
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
export enum ErrorCategory {
|
|
6
|
+
TRANSIENT = 'TRANSIENT',
|
|
7
|
+
PERMISSION = 'PERMISSION',
|
|
8
|
+
VALIDATION = 'VALIDATION',
|
|
9
|
+
SYSTEM = 'SYSTEM',
|
|
10
|
+
UNKNOWN = 'UNKNOWN',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Categorize an error for appropriate handling
|
|
15
|
+
*/
|
|
16
|
+
export function categorizeError(error: Error | unknown): ErrorCategory {
|
|
17
|
+
if (!(error instanceof Error)) {
|
|
18
|
+
return ErrorCategory.UNKNOWN;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const message = error.message.toLowerCase();
|
|
22
|
+
|
|
23
|
+
// Permission errors
|
|
24
|
+
if (
|
|
25
|
+
message.includes('eacces') ||
|
|
26
|
+
message.includes('eperm') ||
|
|
27
|
+
message.includes('permission denied')
|
|
28
|
+
) {
|
|
29
|
+
return ErrorCategory.PERMISSION;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// File not found (validation)
|
|
33
|
+
if (message.includes('enoent') || message.includes('no such file')) {
|
|
34
|
+
return ErrorCategory.VALIDATION;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Transient errors (retryable)
|
|
38
|
+
if (
|
|
39
|
+
message.includes('eagain') ||
|
|
40
|
+
message.includes('etimedout') ||
|
|
41
|
+
message.includes('econnreset') ||
|
|
42
|
+
message.includes('econnrefused') ||
|
|
43
|
+
message.includes('timeout') ||
|
|
44
|
+
message.includes('temporary failure')
|
|
45
|
+
) {
|
|
46
|
+
return ErrorCategory.TRANSIENT;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// System errors
|
|
50
|
+
if (
|
|
51
|
+
message.includes('enospc') ||
|
|
52
|
+
message.includes('disk full') ||
|
|
53
|
+
message.includes('eisdir') ||
|
|
54
|
+
message.includes('enotdir')
|
|
55
|
+
) {
|
|
56
|
+
return ErrorCategory.SYSTEM;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return ErrorCategory.UNKNOWN;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get user-friendly action message for an error
|
|
64
|
+
*/
|
|
65
|
+
export function getUserAction(category: ErrorCategory): string | null {
|
|
66
|
+
switch (category) {
|
|
67
|
+
case ErrorCategory.PERMISSION:
|
|
68
|
+
return 'Check file permissions and ensure Claude Code has access to the config directory';
|
|
69
|
+
case ErrorCategory.VALIDATION:
|
|
70
|
+
return 'Verify the file path and try again';
|
|
71
|
+
case ErrorCategory.SYSTEM:
|
|
72
|
+
return 'Check system resources and Claude Code configuration';
|
|
73
|
+
case ErrorCategory.TRANSIENT:
|
|
74
|
+
return 'The operation failed temporarily. Please try again';
|
|
75
|
+
default:
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if an error is retryable
|
|
82
|
+
*/
|
|
83
|
+
export function isRetryable(error: Error | unknown): boolean {
|
|
84
|
+
return categorizeError(error) === ErrorCategory.TRANSIENT;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Retry configuration
|
|
89
|
+
*/
|
|
90
|
+
export interface RetryOptions {
|
|
91
|
+
maxAttempts: number;
|
|
92
|
+
baseDelay: number;
|
|
93
|
+
maxDelay: number;
|
|
94
|
+
jitter: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
|
98
|
+
maxAttempts: 3,
|
|
99
|
+
baseDelay: 1000,
|
|
100
|
+
maxDelay: 10000,
|
|
101
|
+
jitter: true,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Retry an operation with exponential backoff
|
|
106
|
+
*/
|
|
107
|
+
export async function retryWithBackoff<T>(
|
|
108
|
+
operation: () => Promise<T>,
|
|
109
|
+
options: RetryOptions = DEFAULT_RETRY_OPTIONS
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
let lastError: Error;
|
|
112
|
+
|
|
113
|
+
for (let attempt = 0; attempt < options.maxAttempts; attempt++) {
|
|
114
|
+
try {
|
|
115
|
+
return await operation();
|
|
116
|
+
} catch (error) {
|
|
117
|
+
lastError = error as Error;
|
|
118
|
+
|
|
119
|
+
// Don't retry if not retryable
|
|
120
|
+
if (!isRetryable(error)) {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Calculate delay with exponential backoff
|
|
125
|
+
const delay = Math.min(
|
|
126
|
+
options.baseDelay * Math.pow(2, attempt),
|
|
127
|
+
options.maxDelay
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Add jitter to prevent thundering herd
|
|
131
|
+
const finalDelay = options.jitter
|
|
132
|
+
? delay + Math.random() * 1000
|
|
133
|
+
: delay;
|
|
134
|
+
|
|
135
|
+
// Wait before retry
|
|
136
|
+
await new Promise(resolve => setTimeout(resolve, finalDelay));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
throw lastError!;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create a standardized error response
|
|
145
|
+
*/
|
|
146
|
+
export interface MCPError {
|
|
147
|
+
success: false;
|
|
148
|
+
error: {
|
|
149
|
+
category: ErrorCategory;
|
|
150
|
+
message: string;
|
|
151
|
+
retryable: boolean;
|
|
152
|
+
userAction: string | null;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createMCPError(error: Error | unknown): MCPError {
|
|
157
|
+
const category = categorizeError(error);
|
|
158
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
success: false,
|
|
162
|
+
error: {
|
|
163
|
+
category,
|
|
164
|
+
message,
|
|
165
|
+
retryable: category === ErrorCategory.TRANSIENT,
|
|
166
|
+
userAction: getUserAction(category),
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Error codes for different scenarios
|
|
173
|
+
*/
|
|
174
|
+
export enum ErrorCode {
|
|
175
|
+
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
|
|
176
|
+
PERMISSION_DENIED = 'PERMISSION_DENIED',
|
|
177
|
+
INVALID_JSON = 'INVALID_JSON',
|
|
178
|
+
VALIDATION_FAILED = 'VALIDATION_FAILED',
|
|
179
|
+
PATH_TRAVERSAL = 'PATH_TRAVERSAL',
|
|
180
|
+
DISK_FULL = 'DISK_FULL',
|
|
181
|
+
LOCKED = 'LOCKED',
|
|
182
|
+
UNKNOWN = 'UNKNOWN',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get error code from error
|
|
187
|
+
*/
|
|
188
|
+
export function getErrorCode(error: Error | unknown): ErrorCode {
|
|
189
|
+
const category = categorizeError(error);
|
|
190
|
+
|
|
191
|
+
switch (category) {
|
|
192
|
+
case ErrorCategory.VALIDATION:
|
|
193
|
+
if (error instanceof Error && error.message.includes('JSON')) {
|
|
194
|
+
return ErrorCode.INVALID_JSON;
|
|
195
|
+
}
|
|
196
|
+
return ErrorCode.FILE_NOT_FOUND;
|
|
197
|
+
case ErrorCategory.PERMISSION:
|
|
198
|
+
return ErrorCode.PERMISSION_DENIED;
|
|
199
|
+
case ErrorCategory.SYSTEM:
|
|
200
|
+
if (error instanceof Error && error.message.includes('enospc')) {
|
|
201
|
+
return ErrorCode.DISK_FULL;
|
|
202
|
+
}
|
|
203
|
+
if (error instanceof Error && error.message.includes('etxtbsy')) {
|
|
204
|
+
return ErrorCode.LOCKED;
|
|
205
|
+
}
|
|
206
|
+
return ErrorCode.UNKNOWN;
|
|
207
|
+
default:
|
|
208
|
+
return ErrorCode.UNKNOWN;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// Atomic File Operations - Safe config file writes with backup support
|
|
4
|
+
// ============================================================================
|
|
5
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
6
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
7
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
8
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
9
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
10
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
11
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
12
|
+
});
|
|
13
|
+
};
|
|
14
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
15
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
16
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
17
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
18
|
+
function step(op) {
|
|
19
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
20
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
21
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
22
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
23
|
+
switch (op[0]) {
|
|
24
|
+
case 0: case 1: t = op; break;
|
|
25
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
26
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
27
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
28
|
+
default:
|
|
29
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
30
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
31
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
32
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
33
|
+
if (t[2]) _.ops.pop();
|
|
34
|
+
_.trys.pop(); continue;
|
|
35
|
+
}
|
|
36
|
+
op = body.call(thisArg, _);
|
|
37
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
38
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.atomicWrite = atomicWrite;
|
|
43
|
+
exports.safeReadFile = safeReadFile;
|
|
44
|
+
exports.isFileWritable = isFileWritable;
|
|
45
|
+
exports.normalizeLineEndings = normalizeLineEndings;
|
|
46
|
+
exports.stripBOM = stripBOM;
|
|
47
|
+
exports.formatJSON = formatJSON;
|
|
48
|
+
exports.safeJSONParse = safeJSONParse;
|
|
49
|
+
var promises_1 = require("node:fs/promises");
|
|
50
|
+
var node_fs_1 = require("node:fs");
|
|
51
|
+
var node_path_1 = require("node:path");
|
|
52
|
+
var node_os_1 = require("node:os");
|
|
53
|
+
var node_crypto_1 = require("node:crypto");
|
|
54
|
+
/**
|
|
55
|
+
* Atomic write implementation
|
|
56
|
+
* Writes to a temporary file, then renames over the target
|
|
57
|
+
* This is safe against crashes and concurrent access
|
|
58
|
+
*/
|
|
59
|
+
function atomicWrite(filePath_1, content_1) {
|
|
60
|
+
return __awaiter(this, arguments, void 0, function (filePath, content, options) {
|
|
61
|
+
var _a, createBackup, backupDir, _b, encoding, parentDir, tempDir, tempFileName, tempFilePath, error_1, _c, _d;
|
|
62
|
+
if (options === void 0) { options = {}; }
|
|
63
|
+
return __generator(this, function (_e) {
|
|
64
|
+
switch (_e.label) {
|
|
65
|
+
case 0:
|
|
66
|
+
_a = options.createBackup, createBackup = _a === void 0 ? true : _a, backupDir = options.backupDir, _b = options.encoding, encoding = _b === void 0 ? 'utf8' : _b;
|
|
67
|
+
parentDir = (0, node_path_1.dirname)(filePath);
|
|
68
|
+
return [4 /*yield*/, (0, promises_1.mkdir)(parentDir, { recursive: true })];
|
|
69
|
+
case 1:
|
|
70
|
+
_e.sent();
|
|
71
|
+
if (!(createBackup && (0, node_fs_1.existsSync)(filePath))) return [3 /*break*/, 3];
|
|
72
|
+
return [4 /*yield*/, createBackupFile(filePath, backupDir)];
|
|
73
|
+
case 2:
|
|
74
|
+
_e.sent();
|
|
75
|
+
_e.label = 3;
|
|
76
|
+
case 3: return [4 /*yield*/, (0, promises_1.mkdtemp)((0, node_path_1.join)(getTempDir(), 'mcp-atomic-'))];
|
|
77
|
+
case 4:
|
|
78
|
+
tempDir = _e.sent();
|
|
79
|
+
tempFileName = "".concat((0, node_crypto_1.randomBytes)(16).toString('hex'), ".tmp");
|
|
80
|
+
tempFilePath = (0, node_path_1.join)(tempDir, tempFileName);
|
|
81
|
+
_e.label = 5;
|
|
82
|
+
case 5:
|
|
83
|
+
_e.trys.push([5, 8, 13, 17]);
|
|
84
|
+
// Write to temp file
|
|
85
|
+
return [4 /*yield*/, (0, promises_1.writeFile)(tempFilePath, content, { encoding: encoding, mode: 420 })];
|
|
86
|
+
case 6:
|
|
87
|
+
// Write to temp file
|
|
88
|
+
_e.sent();
|
|
89
|
+
// Atomic rename (overwrites target if exists)
|
|
90
|
+
return [4 /*yield*/, (0, promises_1.rename)(tempFilePath, filePath)];
|
|
91
|
+
case 7:
|
|
92
|
+
// Atomic rename (overwrites target if exists)
|
|
93
|
+
_e.sent();
|
|
94
|
+
return [3 /*break*/, 17];
|
|
95
|
+
case 8:
|
|
96
|
+
error_1 = _e.sent();
|
|
97
|
+
_e.label = 9;
|
|
98
|
+
case 9:
|
|
99
|
+
_e.trys.push([9, 11, , 12]);
|
|
100
|
+
return [4 /*yield*/, (0, promises_1.unlink)(tempFilePath)];
|
|
101
|
+
case 10:
|
|
102
|
+
_e.sent();
|
|
103
|
+
return [3 /*break*/, 12];
|
|
104
|
+
case 11:
|
|
105
|
+
_c = _e.sent();
|
|
106
|
+
return [3 /*break*/, 12];
|
|
107
|
+
case 12: throw error_1;
|
|
108
|
+
case 13:
|
|
109
|
+
_e.trys.push([13, 15, , 16]);
|
|
110
|
+
return [4 /*yield*/, (0, promises_1.unlink)(tempDir).catch(function () { })];
|
|
111
|
+
case 14:
|
|
112
|
+
_e.sent();
|
|
113
|
+
return [3 /*break*/, 16];
|
|
114
|
+
case 15:
|
|
115
|
+
_d = _e.sent();
|
|
116
|
+
return [3 /*break*/, 16];
|
|
117
|
+
case 16: return [7 /*endfinally*/];
|
|
118
|
+
case 17: return [2 /*return*/];
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Create a backup of a file
|
|
125
|
+
*/
|
|
126
|
+
function createBackupFile(filePath, backupDir) {
|
|
127
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
128
|
+
var timestamp, backupFileName, backupPath, backupDirPath, content;
|
|
129
|
+
return __generator(this, function (_a) {
|
|
130
|
+
switch (_a.label) {
|
|
131
|
+
case 0:
|
|
132
|
+
timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
133
|
+
backupFileName = "".concat(basename(filePath), ".backup.").concat(timestamp);
|
|
134
|
+
backupPath = backupDir
|
|
135
|
+
? (0, node_path_1.join)(backupDir, backupFileName)
|
|
136
|
+
: "".concat(filePath, ".backup");
|
|
137
|
+
backupDirPath = (0, node_path_1.dirname)(backupPath);
|
|
138
|
+
return [4 /*yield*/, (0, promises_1.mkdir)(backupDirPath, { recursive: true })];
|
|
139
|
+
case 1:
|
|
140
|
+
_a.sent();
|
|
141
|
+
return [4 /*yield*/, (0, promises_1.readFile)(filePath)];
|
|
142
|
+
case 2:
|
|
143
|
+
content = _a.sent();
|
|
144
|
+
return [4 /*yield*/, (0, promises_1.writeFile)(backupPath, content)];
|
|
145
|
+
case 3:
|
|
146
|
+
_a.sent();
|
|
147
|
+
return [2 /*return*/, backupPath];
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Get temp directory
|
|
154
|
+
*/
|
|
155
|
+
function getTempDir() {
|
|
156
|
+
return (0, node_os_1.tmpdir)();
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Get file basename without extension
|
|
160
|
+
*/
|
|
161
|
+
function basename(filePath) {
|
|
162
|
+
return filePath.split('/').pop() || filePath;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Safe file read with encoding detection
|
|
166
|
+
*/
|
|
167
|
+
function safeReadFile(filePath_1) {
|
|
168
|
+
return __awaiter(this, arguments, void 0, function (filePath, encoding) {
|
|
169
|
+
var content, error_2;
|
|
170
|
+
if (encoding === void 0) { encoding = 'utf8'; }
|
|
171
|
+
return __generator(this, function (_a) {
|
|
172
|
+
switch (_a.label) {
|
|
173
|
+
case 0:
|
|
174
|
+
_a.trys.push([0, 2, , 3]);
|
|
175
|
+
return [4 /*yield*/, (0, promises_1.readFile)(filePath, { encoding: encoding })];
|
|
176
|
+
case 1:
|
|
177
|
+
content = _a.sent();
|
|
178
|
+
// Remove BOM if present
|
|
179
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
180
|
+
return [2 /*return*/, content.slice(1)];
|
|
181
|
+
}
|
|
182
|
+
return [2 /*return*/, content];
|
|
183
|
+
case 2:
|
|
184
|
+
error_2 = _a.sent();
|
|
185
|
+
throw new Error("Failed to read file ".concat(filePath, ": ").concat(error_2.message));
|
|
186
|
+
case 3: return [2 /*return*/];
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Check if file is writable
|
|
193
|
+
*/
|
|
194
|
+
function isFileWritable(filePath) {
|
|
195
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
196
|
+
var _a;
|
|
197
|
+
return __generator(this, function (_b) {
|
|
198
|
+
switch (_b.label) {
|
|
199
|
+
case 0:
|
|
200
|
+
_b.trys.push([0, 3, , 4]);
|
|
201
|
+
return [4 /*yield*/, (0, promises_1.writeFile)(filePath, '', { flag: node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_CREAT })];
|
|
202
|
+
case 1:
|
|
203
|
+
_b.sent();
|
|
204
|
+
return [4 /*yield*/, (0, promises_1.unlink)(filePath)];
|
|
205
|
+
case 2:
|
|
206
|
+
_b.sent();
|
|
207
|
+
return [2 /*return*/, true];
|
|
208
|
+
case 3:
|
|
209
|
+
_a = _b.sent();
|
|
210
|
+
return [2 /*return*/, false];
|
|
211
|
+
case 4: return [2 /*return*/];
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Normalize line endings to LF
|
|
218
|
+
*/
|
|
219
|
+
function normalizeLineEndings(content) {
|
|
220
|
+
return content.replace(/\r\n/g, '\n');
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Detect and strip BOM
|
|
224
|
+
*/
|
|
225
|
+
function stripBOM(content) {
|
|
226
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
227
|
+
return content.slice(1);
|
|
228
|
+
}
|
|
229
|
+
return content;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Format JSON with proper indentation
|
|
233
|
+
*/
|
|
234
|
+
function formatJSON(obj, indent) {
|
|
235
|
+
if (indent === void 0) { indent = 2; }
|
|
236
|
+
return JSON.stringify(obj, null, indent) + '\n';
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Parse JSON with error reporting
|
|
240
|
+
*/
|
|
241
|
+
function safeJSONParse(content, filePath) {
|
|
242
|
+
try {
|
|
243
|
+
return JSON.parse(content);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
var message = error.message;
|
|
247
|
+
var match = message.match(/position (\d+)/);
|
|
248
|
+
var position = match ? match[1] : 'unknown';
|
|
249
|
+
throw new Error("Invalid JSON in ".concat(filePath, " at position ").concat(position, ": ").concat(message));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Atomic File Operations - Safe config file writes with backup support
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
import { writeFile, rename, unlink, readFile, mkdir, mkdtemp } from 'node:fs/promises';
|
|
6
|
+
import { existsSync, constants } from 'node:fs';
|
|
7
|
+
import { join, dirname } from 'node:path';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import type { AtomicWriteOptions } from '../types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Atomic write implementation
|
|
14
|
+
* Writes to a temporary file, then renames over the target
|
|
15
|
+
* This is safe against crashes and concurrent access
|
|
16
|
+
*/
|
|
17
|
+
export async function atomicWrite(
|
|
18
|
+
filePath: string,
|
|
19
|
+
content: string,
|
|
20
|
+
options: AtomicWriteOptions = {}
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const {
|
|
23
|
+
createBackup = true,
|
|
24
|
+
backupDir,
|
|
25
|
+
encoding = 'utf8',
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
// Create parent directory if it doesn't exist
|
|
29
|
+
const parentDir = dirname(filePath);
|
|
30
|
+
await mkdir(parentDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
// Create backup if requested and file exists
|
|
33
|
+
if (createBackup && existsSync(filePath)) {
|
|
34
|
+
await createBackupFile(filePath, backupDir);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Create temp file in OS temp directory (same filesystem for atomic rename)
|
|
38
|
+
const tempDir = await mkdtemp(join(getTempDir(), 'mcp-atomic-'));
|
|
39
|
+
const tempFileName = `${randomBytes(16).toString('hex')}.tmp`;
|
|
40
|
+
const tempFilePath = join(tempDir, tempFileName);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
// Write to temp file
|
|
44
|
+
await writeFile(tempFilePath, content, { encoding, mode: 0o644 });
|
|
45
|
+
|
|
46
|
+
// Atomic rename (overwrites target if exists)
|
|
47
|
+
await rename(tempFilePath, filePath);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// Clean up temp file if rename failed
|
|
50
|
+
try {
|
|
51
|
+
await unlink(tempFilePath);
|
|
52
|
+
} catch {
|
|
53
|
+
// Ignore cleanup errors
|
|
54
|
+
}
|
|
55
|
+
throw error;
|
|
56
|
+
} finally {
|
|
57
|
+
// Clean up temp directory
|
|
58
|
+
try {
|
|
59
|
+
await unlink(tempDir).catch(() => {});
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore cleanup errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a backup of a file
|
|
68
|
+
*/
|
|
69
|
+
async function createBackupFile(filePath: string, backupDir?: string): Promise<string> {
|
|
70
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
71
|
+
const backupFileName = `${basename(filePath)}.backup.${timestamp}`;
|
|
72
|
+
const backupPath = backupDir
|
|
73
|
+
? join(backupDir, backupFileName)
|
|
74
|
+
: `${filePath}.backup`;
|
|
75
|
+
|
|
76
|
+
// Ensure backup directory exists
|
|
77
|
+
const backupDirPath = dirname(backupPath);
|
|
78
|
+
await mkdir(backupDirPath, { recursive: true });
|
|
79
|
+
|
|
80
|
+
// Copy file to backup location
|
|
81
|
+
const content = await readFile(filePath);
|
|
82
|
+
await writeFile(backupPath, content);
|
|
83
|
+
|
|
84
|
+
return backupPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get temp directory
|
|
89
|
+
*/
|
|
90
|
+
function getTempDir(): string {
|
|
91
|
+
return tmpdir();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get file basename without extension
|
|
96
|
+
*/
|
|
97
|
+
function basename(filePath: string): string {
|
|
98
|
+
return filePath.split('/').pop() || filePath;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Safe file read with encoding detection
|
|
103
|
+
*/
|
|
104
|
+
export async function safeReadFile(
|
|
105
|
+
filePath: string,
|
|
106
|
+
encoding: BufferEncoding = 'utf8'
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
try {
|
|
109
|
+
const content = await readFile(filePath, { encoding });
|
|
110
|
+
|
|
111
|
+
// Remove BOM if present
|
|
112
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
113
|
+
return content.slice(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return content;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw new Error(`Failed to read file ${filePath}: ${(error as Error).message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if file is writable
|
|
124
|
+
*/
|
|
125
|
+
export async function isFileWritable(filePath: string): Promise<boolean> {
|
|
126
|
+
try {
|
|
127
|
+
await writeFile(filePath, '', { flag: constants.O_WRONLY | constants.O_CREAT });
|
|
128
|
+
await unlink(filePath);
|
|
129
|
+
return true;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize line endings to LF
|
|
137
|
+
*/
|
|
138
|
+
export function normalizeLineEndings(content: string): string {
|
|
139
|
+
return content.replace(/\r\n/g, '\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Detect and strip BOM
|
|
144
|
+
*/
|
|
145
|
+
export function stripBOM(content: string): string {
|
|
146
|
+
if (content.charCodeAt(0) === 0xFEFF) {
|
|
147
|
+
return content.slice(1);
|
|
148
|
+
}
|
|
149
|
+
return content;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Format JSON with proper indentation
|
|
154
|
+
*/
|
|
155
|
+
export function formatJSON(obj: unknown, indent: number = 2): string {
|
|
156
|
+
return JSON.stringify(obj, null, indent) + '\n';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse JSON with error reporting
|
|
161
|
+
*/
|
|
162
|
+
export function safeJSONParse<T>(content: string, filePath: string): T {
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(content) as T;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = (error as Error).message;
|
|
167
|
+
const match = message.match(/position (\d+)/);
|
|
168
|
+
const position = match ? match[1] : 'unknown';
|
|
169
|
+
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Invalid JSON in ${filePath} at position ${position}: ${message}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|