agileflow 2.89.2 → 2.90.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/CHANGELOG.md +10 -0
- package/README.md +3 -3
- package/lib/content-sanitizer.js +463 -0
- package/lib/error-codes.js +544 -0
- package/lib/errors.js +336 -5
- package/lib/feedback.js +561 -0
- package/lib/path-resolver.js +396 -0
- package/lib/placeholder-registry.js +617 -0
- package/lib/session-registry.js +461 -0
- package/lib/smart-json-file.js +653 -0
- package/lib/table-formatter.js +504 -0
- package/lib/transient-status.js +374 -0
- package/lib/ui-manager.js +612 -0
- package/lib/validate-args.js +213 -0
- package/lib/validate-names.js +143 -0
- package/lib/validate-paths.js +434 -0
- package/lib/validate.js +38 -584
- package/package.json +4 -1
- package/scripts/agileflow-configure.js +40 -1440
- package/scripts/agileflow-welcome.js +2 -1
- package/scripts/check-update.js +16 -3
- package/scripts/lib/configure-detect.js +383 -0
- package/scripts/lib/configure-features.js +811 -0
- package/scripts/lib/configure-repair.js +314 -0
- package/scripts/lib/configure-utils.js +115 -0
- package/scripts/lib/frontmatter-parser.js +3 -3
- package/scripts/lib/sessionRegistry.js +682 -0
- package/scripts/obtain-context.js +417 -113
- package/scripts/ralph-loop.js +1 -1
- package/scripts/session-manager.js +77 -10
- package/scripts/tui/App.js +176 -0
- package/scripts/tui/index.js +75 -0
- package/scripts/tui/lib/crashRecovery.js +302 -0
- package/scripts/tui/lib/eventStream.js +316 -0
- package/scripts/tui/lib/keyboard.js +252 -0
- package/scripts/tui/lib/loopControl.js +371 -0
- package/scripts/tui/panels/OutputPanel.js +278 -0
- package/scripts/tui/panels/SessionPanel.js +178 -0
- package/scripts/tui/panels/TracePanel.js +333 -0
- package/src/core/commands/tui.md +91 -0
- package/tools/cli/commands/config.js +10 -33
- package/tools/cli/commands/doctor.js +48 -40
- package/tools/cli/commands/list.js +49 -37
- package/tools/cli/commands/status.js +13 -37
- package/tools/cli/commands/uninstall.js +12 -41
- package/tools/cli/installers/core/installer.js +75 -12
- package/tools/cli/installers/ide/_interface.js +238 -0
- package/tools/cli/installers/ide/codex.js +2 -2
- package/tools/cli/installers/ide/manager.js +15 -0
- package/tools/cli/lib/command-context.js +374 -0
- package/tools/cli/lib/config-manager.js +394 -0
- package/tools/cli/lib/content-injector.js +69 -16
- package/tools/cli/lib/ide-errors.js +163 -29
- package/tools/cli/lib/ide-registry.js +186 -0
- package/tools/cli/lib/npm-utils.js +16 -3
- package/tools/cli/lib/self-update.js +148 -0
- package/tools/cli/lib/validation-middleware.js +491 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smart-json-file.js - Safe JSON File Operations with Retry Logic
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic writes, automatic retries, and error code integration
|
|
5
|
+
* for all JSON file operations in AgileFlow.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Atomic writes (write to temp file, then rename)
|
|
9
|
+
* - Configurable retry logic with exponential backoff
|
|
10
|
+
* - Integration with error-codes.js for typed errors
|
|
11
|
+
* - Schema validation support
|
|
12
|
+
* - Automatic directory creation
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* const SmartJsonFile = require('./smart-json-file');
|
|
16
|
+
*
|
|
17
|
+
* // Basic usage
|
|
18
|
+
* const file = new SmartJsonFile('/path/to/file.json');
|
|
19
|
+
* const data = await file.read();
|
|
20
|
+
* await file.write({ key: 'value' });
|
|
21
|
+
*
|
|
22
|
+
* // With options
|
|
23
|
+
* const file = new SmartJsonFile('/path/to/file.json', {
|
|
24
|
+
* retries: 3,
|
|
25
|
+
* backoff: 100,
|
|
26
|
+
* createDir: true,
|
|
27
|
+
* schema: mySchema
|
|
28
|
+
* });
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const path = require('path');
|
|
33
|
+
const { createTypedError, getErrorCodeFromError, ErrorCodes } = require('./error-codes');
|
|
34
|
+
|
|
35
|
+
// Debug logging
|
|
36
|
+
const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
|
|
37
|
+
|
|
38
|
+
function debugLog(operation, details) {
|
|
39
|
+
if (DEBUG) {
|
|
40
|
+
const timestamp = new Date().toISOString();
|
|
41
|
+
console.error(`[${timestamp}] [SmartJsonFile] ${operation}:`, JSON.stringify(details));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Security: Secure file permission mode for sensitive config files
|
|
46
|
+
// 0o600 = owner read/write only (no group or world access)
|
|
47
|
+
const SECURE_FILE_MODE = 0o600;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if file permissions are too permissive (security risk)
|
|
51
|
+
* @param {number} mode - File mode (from fs.statSync)
|
|
52
|
+
* @returns {{ok: boolean, warning?: string}} Check result
|
|
53
|
+
*/
|
|
54
|
+
function checkFilePermissions(mode) {
|
|
55
|
+
// Skip permission checks on Windows (different permission model)
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Extract permission bits (last 9 bits)
|
|
61
|
+
const permissions = mode & 0o777;
|
|
62
|
+
|
|
63
|
+
// Check for group/world readable/writable (security risk)
|
|
64
|
+
const groupRead = permissions & 0o040;
|
|
65
|
+
const groupWrite = permissions & 0o020;
|
|
66
|
+
const worldRead = permissions & 0o004;
|
|
67
|
+
const worldWrite = permissions & 0o002;
|
|
68
|
+
|
|
69
|
+
if (worldWrite) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
warning: 'File is world-writable (mode: ' + permissions.toString(8) + '). Security risk - others can modify.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (worldRead) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
warning: 'File is world-readable (mode: ' + permissions.toString(8) + '). May expose sensitive config.',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (groupWrite) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
warning: 'File is group-writable (mode: ' + permissions.toString(8) + '). Consider restricting to 0600.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (groupRead) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
warning: 'File is group-readable (mode: ' + permissions.toString(8) + '). Consider restricting to 0600.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set secure permissions on a file (0o600 - owner only)
|
|
102
|
+
* @param {string} filePath - Path to the file
|
|
103
|
+
* @returns {{ok: boolean, error?: Error}}
|
|
104
|
+
*/
|
|
105
|
+
function setSecurePermissions(filePath) {
|
|
106
|
+
// Skip on Windows
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
return { ok: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
fs.chmodSync(filePath, SECURE_FILE_MODE);
|
|
113
|
+
debugLog('setSecurePermissions', { filePath, mode: SECURE_FILE_MODE.toString(8) });
|
|
114
|
+
return { ok: true };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
const error = createTypedError(`Failed to set secure permissions on ${filePath}: ${err.message}`, 'EPERM', {
|
|
117
|
+
cause: err,
|
|
118
|
+
context: { filePath, mode: SECURE_FILE_MODE },
|
|
119
|
+
});
|
|
120
|
+
return { ok: false, error };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate a unique temporary file path
|
|
126
|
+
* @param {string} filePath - Original file path
|
|
127
|
+
* @returns {string} Temporary file path
|
|
128
|
+
*/
|
|
129
|
+
function getTempPath(filePath) {
|
|
130
|
+
const dir = path.dirname(filePath);
|
|
131
|
+
const ext = path.extname(filePath);
|
|
132
|
+
const base = path.basename(filePath, ext);
|
|
133
|
+
const timestamp = Date.now();
|
|
134
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
135
|
+
return path.join(dir, `.${base}.${timestamp}.${random}${ext}.tmp`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Sleep for a specified duration
|
|
140
|
+
* @param {number} ms - Milliseconds to sleep
|
|
141
|
+
* @returns {Promise<void>}
|
|
142
|
+
*/
|
|
143
|
+
function sleep(ms) {
|
|
144
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* SmartJsonFile - Safe JSON file operations with retry logic
|
|
149
|
+
*/
|
|
150
|
+
class SmartJsonFile {
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} filePath - Absolute path to JSON file
|
|
153
|
+
* @param {Object} [options={}] - Configuration options
|
|
154
|
+
* @param {number} [options.retries=3] - Number of retry attempts
|
|
155
|
+
* @param {number} [options.backoff=100] - Initial backoff in ms (doubles each retry)
|
|
156
|
+
* @param {boolean} [options.createDir=true] - Create parent directory if missing
|
|
157
|
+
* @param {number} [options.spaces=2] - JSON indentation spaces
|
|
158
|
+
* @param {Function} [options.schema] - Optional validation function (throws on invalid)
|
|
159
|
+
* @param {*} [options.defaultValue] - Default value if file doesn't exist
|
|
160
|
+
* @param {boolean} [options.secureMode=false] - Enforce 0o600 permissions on write
|
|
161
|
+
* @param {boolean} [options.warnInsecure=false] - Warn if file has insecure permissions on read
|
|
162
|
+
*/
|
|
163
|
+
constructor(filePath, options = {}) {
|
|
164
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
165
|
+
throw createTypedError('filePath is required and must be a string', 'EINVAL', {
|
|
166
|
+
context: { provided: typeof filePath },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!path.isAbsolute(filePath)) {
|
|
171
|
+
throw createTypedError('filePath must be an absolute path', 'EINVAL', {
|
|
172
|
+
context: { provided: filePath },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.filePath = filePath;
|
|
177
|
+
this.retries = options.retries ?? 3;
|
|
178
|
+
this.backoff = options.backoff ?? 100;
|
|
179
|
+
this.createDir = options.createDir ?? true;
|
|
180
|
+
this.spaces = options.spaces ?? 2;
|
|
181
|
+
this.schema = options.schema ?? null;
|
|
182
|
+
this.defaultValue = options.defaultValue;
|
|
183
|
+
this.secureMode = options.secureMode ?? false;
|
|
184
|
+
this.warnInsecure = options.warnInsecure ?? false;
|
|
185
|
+
|
|
186
|
+
debugLog('constructor', {
|
|
187
|
+
filePath,
|
|
188
|
+
options: { retries: this.retries, backoff: this.backoff, secureMode: this.secureMode },
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Read and parse JSON file
|
|
194
|
+
* @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
|
|
195
|
+
*/
|
|
196
|
+
async read() {
|
|
197
|
+
let lastError = null;
|
|
198
|
+
let attempt = 0;
|
|
199
|
+
|
|
200
|
+
while (attempt <= this.retries) {
|
|
201
|
+
try {
|
|
202
|
+
debugLog('read', { filePath: this.filePath, attempt });
|
|
203
|
+
|
|
204
|
+
// Check if file exists
|
|
205
|
+
if (!fs.existsSync(this.filePath)) {
|
|
206
|
+
if (this.defaultValue !== undefined) {
|
|
207
|
+
debugLog('read', { status: 'using default value' });
|
|
208
|
+
return { ok: true, data: this.defaultValue };
|
|
209
|
+
}
|
|
210
|
+
const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
|
|
211
|
+
context: { filePath: this.filePath },
|
|
212
|
+
});
|
|
213
|
+
return { ok: false, error };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Read file
|
|
217
|
+
const content = fs.readFileSync(this.filePath, 'utf8');
|
|
218
|
+
|
|
219
|
+
// Security: Check file permissions if warnInsecure is enabled
|
|
220
|
+
if (this.warnInsecure) {
|
|
221
|
+
try {
|
|
222
|
+
const stats = fs.statSync(this.filePath);
|
|
223
|
+
const permCheck = checkFilePermissions(stats.mode);
|
|
224
|
+
if (!permCheck.ok) {
|
|
225
|
+
debugLog('read', { security: 'insecure permissions', warning: permCheck.warning });
|
|
226
|
+
// Log warning to stderr (non-blocking)
|
|
227
|
+
console.error(`[Security Warning] ${this.filePath}: ${permCheck.warning}`);
|
|
228
|
+
}
|
|
229
|
+
} catch (statErr) {
|
|
230
|
+
debugLog('read', { security: 'could not check permissions', error: statErr.message });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Parse JSON
|
|
235
|
+
let data;
|
|
236
|
+
try {
|
|
237
|
+
data = JSON.parse(content);
|
|
238
|
+
} catch (parseError) {
|
|
239
|
+
const error = createTypedError(
|
|
240
|
+
`Invalid JSON in ${this.filePath}: ${parseError.message}`,
|
|
241
|
+
'EPARSE',
|
|
242
|
+
{ cause: parseError, context: { filePath: this.filePath } }
|
|
243
|
+
);
|
|
244
|
+
return { ok: false, error };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Validate schema if provided
|
|
248
|
+
if (this.schema) {
|
|
249
|
+
try {
|
|
250
|
+
this.schema(data);
|
|
251
|
+
} catch (schemaError) {
|
|
252
|
+
const error = createTypedError(
|
|
253
|
+
`Schema validation failed for ${this.filePath}: ${schemaError.message}`,
|
|
254
|
+
'ESCHEMA',
|
|
255
|
+
{ cause: schemaError, context: { filePath: this.filePath } }
|
|
256
|
+
);
|
|
257
|
+
return { ok: false, error };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
debugLog('read', { status: 'success' });
|
|
262
|
+
return { ok: true, data };
|
|
263
|
+
} catch (err) {
|
|
264
|
+
lastError = err;
|
|
265
|
+
debugLog('read', { status: 'error', error: err.message, attempt });
|
|
266
|
+
|
|
267
|
+
// Don't retry for certain errors
|
|
268
|
+
const errorCode = getErrorCodeFromError(err);
|
|
269
|
+
if (errorCode.code === 'EACCES' || errorCode.code === 'EPERM') {
|
|
270
|
+
// Permission errors won't resolve with retries
|
|
271
|
+
const error = createTypedError(`Permission denied reading ${this.filePath}`, 'EACCES', {
|
|
272
|
+
cause: err,
|
|
273
|
+
context: { filePath: this.filePath },
|
|
274
|
+
});
|
|
275
|
+
return { ok: false, error };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Wait before retrying
|
|
279
|
+
if (attempt < this.retries) {
|
|
280
|
+
const waitTime = this.backoff * Math.pow(2, attempt);
|
|
281
|
+
debugLog('read', { status: 'retrying', waitTime });
|
|
282
|
+
await sleep(waitTime);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
attempt++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// All retries exhausted
|
|
290
|
+
const error = createTypedError(
|
|
291
|
+
`Failed to read ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
|
|
292
|
+
'EUNKNOWN',
|
|
293
|
+
{ cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
|
|
294
|
+
);
|
|
295
|
+
return { ok: false, error };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Write data to JSON file atomically
|
|
300
|
+
* @param {any} data - Data to write
|
|
301
|
+
* @returns {Promise<{ok: boolean, error?: Error}>} Result object
|
|
302
|
+
*/
|
|
303
|
+
async write(data) {
|
|
304
|
+
let lastError = null;
|
|
305
|
+
let attempt = 0;
|
|
306
|
+
|
|
307
|
+
// Validate schema before writing
|
|
308
|
+
if (this.schema) {
|
|
309
|
+
try {
|
|
310
|
+
this.schema(data);
|
|
311
|
+
} catch (schemaError) {
|
|
312
|
+
const error = createTypedError(
|
|
313
|
+
`Schema validation failed: ${schemaError.message}`,
|
|
314
|
+
'ESCHEMA',
|
|
315
|
+
{ cause: schemaError, context: { filePath: this.filePath } }
|
|
316
|
+
);
|
|
317
|
+
return { ok: false, error };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
while (attempt <= this.retries) {
|
|
322
|
+
const tempPath = getTempPath(this.filePath);
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
debugLog('write', { filePath: this.filePath, tempPath, attempt });
|
|
326
|
+
|
|
327
|
+
// Create parent directory if needed
|
|
328
|
+
const dir = path.dirname(this.filePath);
|
|
329
|
+
if (this.createDir && !fs.existsSync(dir)) {
|
|
330
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
+
debugLog('write', { status: 'created directory', dir });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Serialize data
|
|
335
|
+
const content = JSON.stringify(data, null, this.spaces) + '\n';
|
|
336
|
+
|
|
337
|
+
// Write to temp file
|
|
338
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
339
|
+
debugLog('write', { status: 'wrote temp file' });
|
|
340
|
+
|
|
341
|
+
// Atomic rename
|
|
342
|
+
fs.renameSync(tempPath, this.filePath);
|
|
343
|
+
|
|
344
|
+
// Security: Set secure permissions if secureMode is enabled
|
|
345
|
+
if (this.secureMode) {
|
|
346
|
+
const permResult = setSecurePermissions(this.filePath);
|
|
347
|
+
if (!permResult.ok) {
|
|
348
|
+
debugLog('write', { status: 'warning', security: 'failed to set secure permissions' });
|
|
349
|
+
// Don't fail the write, just warn
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
debugLog('write', { status: 'success' });
|
|
354
|
+
|
|
355
|
+
return { ok: true };
|
|
356
|
+
} catch (err) {
|
|
357
|
+
lastError = err;
|
|
358
|
+
debugLog('write', { status: 'error', error: err.message, attempt });
|
|
359
|
+
|
|
360
|
+
// Clean up temp file if it exists
|
|
361
|
+
try {
|
|
362
|
+
if (fs.existsSync(tempPath)) {
|
|
363
|
+
fs.unlinkSync(tempPath);
|
|
364
|
+
}
|
|
365
|
+
} catch {
|
|
366
|
+
// Ignore cleanup errors
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Don't retry for certain errors
|
|
370
|
+
const errorCode = getErrorCodeFromError(err);
|
|
371
|
+
if (
|
|
372
|
+
errorCode.code === 'EACCES' ||
|
|
373
|
+
errorCode.code === 'EPERM' ||
|
|
374
|
+
errorCode.code === 'EROFS'
|
|
375
|
+
) {
|
|
376
|
+
const error = createTypedError(
|
|
377
|
+
`Permission denied writing ${this.filePath}`,
|
|
378
|
+
errorCode.code,
|
|
379
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
380
|
+
);
|
|
381
|
+
return { ok: false, error };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Wait before retrying
|
|
385
|
+
if (attempt < this.retries) {
|
|
386
|
+
const waitTime = this.backoff * Math.pow(2, attempt);
|
|
387
|
+
debugLog('write', { status: 'retrying', waitTime });
|
|
388
|
+
await sleep(waitTime);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
attempt++;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// All retries exhausted
|
|
396
|
+
const error = createTypedError(
|
|
397
|
+
`Failed to write ${this.filePath} after ${this.retries + 1} attempts: ${lastError?.message}`,
|
|
398
|
+
'EUNKNOWN',
|
|
399
|
+
{ cause: lastError, context: { filePath: this.filePath, attempts: this.retries + 1 } }
|
|
400
|
+
);
|
|
401
|
+
return { ok: false, error };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Read, modify, and write atomically
|
|
406
|
+
* @param {Function} modifier - Function that takes current data and returns modified data
|
|
407
|
+
* @returns {Promise<{ok: boolean, data?: any, error?: Error}>} Result object
|
|
408
|
+
*/
|
|
409
|
+
async modify(modifier) {
|
|
410
|
+
debugLog('modify', { filePath: this.filePath });
|
|
411
|
+
|
|
412
|
+
// Read current data
|
|
413
|
+
const readResult = await this.read();
|
|
414
|
+
if (!readResult.ok) {
|
|
415
|
+
// If file doesn't exist but we have a default value, use that
|
|
416
|
+
if (readResult.error?.errorCode === 'ENOENT' && this.defaultValue !== undefined) {
|
|
417
|
+
readResult.ok = true;
|
|
418
|
+
readResult.data = this.defaultValue;
|
|
419
|
+
delete readResult.error;
|
|
420
|
+
} else {
|
|
421
|
+
return readResult;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Apply modifier
|
|
426
|
+
let newData;
|
|
427
|
+
try {
|
|
428
|
+
newData = await modifier(readResult.data);
|
|
429
|
+
} catch (modifyError) {
|
|
430
|
+
const error = createTypedError(`Modifier function failed: ${modifyError.message}`, 'EINVAL', {
|
|
431
|
+
cause: modifyError,
|
|
432
|
+
context: { filePath: this.filePath },
|
|
433
|
+
});
|
|
434
|
+
return { ok: false, error };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Write modified data
|
|
438
|
+
const writeResult = await this.write(newData);
|
|
439
|
+
if (!writeResult.ok) {
|
|
440
|
+
return writeResult;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return { ok: true, data: newData };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Check if file exists
|
|
448
|
+
* @returns {boolean}
|
|
449
|
+
*/
|
|
450
|
+
exists() {
|
|
451
|
+
return fs.existsSync(this.filePath);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Delete the file
|
|
456
|
+
* @returns {Promise<{ok: boolean, error?: Error}>}
|
|
457
|
+
*/
|
|
458
|
+
async delete() {
|
|
459
|
+
try {
|
|
460
|
+
if (!fs.existsSync(this.filePath)) {
|
|
461
|
+
return { ok: true }; // Already doesn't exist
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
fs.unlinkSync(this.filePath);
|
|
465
|
+
debugLog('delete', { filePath: this.filePath, status: 'success' });
|
|
466
|
+
return { ok: true };
|
|
467
|
+
} catch (err) {
|
|
468
|
+
const errorCode = getErrorCodeFromError(err);
|
|
469
|
+
const error = createTypedError(
|
|
470
|
+
`Failed to delete ${this.filePath}: ${err.message}`,
|
|
471
|
+
errorCode.code,
|
|
472
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
473
|
+
);
|
|
474
|
+
return { ok: false, error };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Synchronous read - for cases where async isn't possible
|
|
480
|
+
* @returns {{ok: boolean, data?: any, error?: Error}}
|
|
481
|
+
*/
|
|
482
|
+
readSync() {
|
|
483
|
+
try {
|
|
484
|
+
if (!fs.existsSync(this.filePath)) {
|
|
485
|
+
if (this.defaultValue !== undefined) {
|
|
486
|
+
return { ok: true, data: this.defaultValue };
|
|
487
|
+
}
|
|
488
|
+
const error = createTypedError(`File not found: ${this.filePath}`, 'ENOENT', {
|
|
489
|
+
context: { filePath: this.filePath },
|
|
490
|
+
});
|
|
491
|
+
return { ok: false, error };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const content = fs.readFileSync(this.filePath, 'utf8');
|
|
495
|
+
const data = JSON.parse(content);
|
|
496
|
+
|
|
497
|
+
if (this.schema) {
|
|
498
|
+
this.schema(data);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { ok: true, data };
|
|
502
|
+
} catch (err) {
|
|
503
|
+
const errorCode = getErrorCodeFromError(err);
|
|
504
|
+
const error = createTypedError(
|
|
505
|
+
`Failed to read ${this.filePath}: ${err.message}`,
|
|
506
|
+
errorCode.code,
|
|
507
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
508
|
+
);
|
|
509
|
+
return { ok: false, error };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Synchronous write - for cases where async isn't possible
|
|
515
|
+
* @param {any} data - Data to write
|
|
516
|
+
* @returns {{ok: boolean, error?: Error}}
|
|
517
|
+
*/
|
|
518
|
+
writeSync(data) {
|
|
519
|
+
const tempPath = getTempPath(this.filePath);
|
|
520
|
+
|
|
521
|
+
try {
|
|
522
|
+
if (this.schema) {
|
|
523
|
+
this.schema(data);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const dir = path.dirname(this.filePath);
|
|
527
|
+
if (this.createDir && !fs.existsSync(dir)) {
|
|
528
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const content = JSON.stringify(data, null, this.spaces) + '\n';
|
|
532
|
+
fs.writeFileSync(tempPath, content, 'utf8');
|
|
533
|
+
fs.renameSync(tempPath, this.filePath);
|
|
534
|
+
|
|
535
|
+
// Security: Set secure permissions if secureMode is enabled
|
|
536
|
+
if (this.secureMode) {
|
|
537
|
+
const permResult = setSecurePermissions(this.filePath);
|
|
538
|
+
if (!permResult.ok) {
|
|
539
|
+
debugLog('writeSync', { status: 'warning', security: 'failed to set secure permissions' });
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return { ok: true };
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Clean up temp file
|
|
546
|
+
try {
|
|
547
|
+
if (fs.existsSync(tempPath)) {
|
|
548
|
+
fs.unlinkSync(tempPath);
|
|
549
|
+
}
|
|
550
|
+
} catch {
|
|
551
|
+
// Ignore
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const errorCode = getErrorCodeFromError(err);
|
|
555
|
+
const error = createTypedError(
|
|
556
|
+
`Failed to write ${this.filePath}: ${err.message}`,
|
|
557
|
+
errorCode.code,
|
|
558
|
+
{ cause: err, context: { filePath: this.filePath } }
|
|
559
|
+
);
|
|
560
|
+
return { ok: false, error };
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Default max age for temp files (24 hours in milliseconds)
|
|
566
|
+
const DEFAULT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Clean up orphaned temp files in a directory
|
|
570
|
+
*
|
|
571
|
+
* Removes files matching the temp file pattern that are older than maxAge.
|
|
572
|
+
* Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
|
|
573
|
+
*
|
|
574
|
+
* @param {string} directory - Directory to clean
|
|
575
|
+
* @param {Object} [options={}] - Cleanup options
|
|
576
|
+
* @param {number} [options.maxAgeMs=86400000] - Max age in ms (default: 24 hours)
|
|
577
|
+
* @param {boolean} [options.dryRun=false] - Don't delete, just report
|
|
578
|
+
* @returns {{ok: boolean, cleaned: string[], errors: string[]}}
|
|
579
|
+
*/
|
|
580
|
+
function cleanupTempFiles(directory, options = {}) {
|
|
581
|
+
const { maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS, dryRun = false } = options;
|
|
582
|
+
|
|
583
|
+
const cleaned = [];
|
|
584
|
+
const errors = [];
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
if (!fs.existsSync(directory)) {
|
|
588
|
+
return { ok: true, cleaned, errors };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
const entries = fs.readdirSync(directory);
|
|
593
|
+
|
|
594
|
+
// Pattern: .{basename}.{timestamp}.{random}.{ext}.tmp
|
|
595
|
+
const tempFilePattern = /^\.[^.]+\.\d+\.[a-z0-9]+\.[^.]+\.tmp$/;
|
|
596
|
+
|
|
597
|
+
for (const entry of entries) {
|
|
598
|
+
// Check if it matches temp file pattern
|
|
599
|
+
if (!tempFilePattern.test(entry)) continue;
|
|
600
|
+
|
|
601
|
+
const filePath = path.join(directory, entry);
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
const stat = fs.statSync(filePath);
|
|
605
|
+
|
|
606
|
+
// Skip if not a file
|
|
607
|
+
if (!stat.isFile()) continue;
|
|
608
|
+
|
|
609
|
+
// Check age
|
|
610
|
+
const age = now - stat.mtimeMs;
|
|
611
|
+
if (age < maxAgeMs) continue;
|
|
612
|
+
|
|
613
|
+
// Delete the temp file
|
|
614
|
+
if (!dryRun) {
|
|
615
|
+
fs.unlinkSync(filePath);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
cleaned.push(filePath);
|
|
619
|
+
debugLog('cleanupTempFiles', { action: dryRun ? 'would delete' : 'deleted', filePath, ageHours: Math.round(age / 3600000) });
|
|
620
|
+
} catch (err) {
|
|
621
|
+
errors.push(`${filePath}: ${err.message}`);
|
|
622
|
+
debugLog('cleanupTempFiles', { action: 'error', filePath, error: err.message });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return { ok: errors.length === 0, cleaned, errors };
|
|
627
|
+
} catch (err) {
|
|
628
|
+
errors.push(`Directory read error: ${err.message}`);
|
|
629
|
+
return { ok: false, cleaned, errors };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Clean up temp files in the directory of a specific JSON file
|
|
635
|
+
*
|
|
636
|
+
* @param {string} filePath - Path to the JSON file
|
|
637
|
+
* @param {Object} [options={}] - Cleanup options
|
|
638
|
+
* @returns {{ok: boolean, cleaned: string[], errors: string[]}}
|
|
639
|
+
*/
|
|
640
|
+
function cleanupTempFilesFor(filePath, options = {}) {
|
|
641
|
+
const directory = path.dirname(filePath);
|
|
642
|
+
return cleanupTempFiles(directory, options);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
module.exports = SmartJsonFile;
|
|
646
|
+
|
|
647
|
+
// Export helper functions for external use
|
|
648
|
+
module.exports.SECURE_FILE_MODE = SECURE_FILE_MODE;
|
|
649
|
+
module.exports.checkFilePermissions = checkFilePermissions;
|
|
650
|
+
module.exports.setSecurePermissions = setSecurePermissions;
|
|
651
|
+
module.exports.cleanupTempFiles = cleanupTempFiles;
|
|
652
|
+
module.exports.cleanupTempFilesFor = cleanupTempFilesFor;
|
|
653
|
+
module.exports.DEFAULT_TEMP_MAX_AGE_MS = DEFAULT_TEMP_MAX_AGE_MS;
|