auq-mcp-server 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.
Files changed (66) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +176 -0
  3. package/dist/__tests__/schema-validation.test.js +137 -0
  4. package/dist/__tests__/server.integration.test.js +263 -0
  5. package/dist/add.js +1 -0
  6. package/dist/add.test.js +5 -0
  7. package/dist/bin/auq.js +245 -0
  8. package/dist/bin/test-session-menu.js +28 -0
  9. package/dist/bin/test-tabbar.js +42 -0
  10. package/dist/file-utils.js +59 -0
  11. package/dist/format/ResponseFormatter.js +206 -0
  12. package/dist/format/__tests__/ResponseFormatter.test.js +380 -0
  13. package/dist/package.json +74 -0
  14. package/dist/server.js +107 -0
  15. package/dist/session/ResponseFormatter.js +130 -0
  16. package/dist/session/SessionManager.js +474 -0
  17. package/dist/session/__tests__/ResponseFormatter.test.js +417 -0
  18. package/dist/session/__tests__/SessionManager.test.js +553 -0
  19. package/dist/session/__tests__/atomic-operations.test.js +345 -0
  20. package/dist/session/__tests__/file-watcher.test.js +311 -0
  21. package/dist/session/__tests__/workflow.integration.test.js +334 -0
  22. package/dist/session/atomic-operations.js +307 -0
  23. package/dist/session/file-watcher.js +218 -0
  24. package/dist/session/index.js +7 -0
  25. package/dist/session/types.js +20 -0
  26. package/dist/session/utils.js +125 -0
  27. package/dist/session-manager.js +171 -0
  28. package/dist/session-watcher.js +110 -0
  29. package/dist/src/__tests__/schema-validation.test.js +170 -0
  30. package/dist/src/__tests__/server.integration.test.js +274 -0
  31. package/dist/src/add.js +1 -0
  32. package/dist/src/add.test.js +5 -0
  33. package/dist/src/server.js +163 -0
  34. package/dist/src/session/ResponseFormatter.js +163 -0
  35. package/dist/src/session/SessionManager.js +572 -0
  36. package/dist/src/session/__tests__/ResponseFormatter.test.js +741 -0
  37. package/dist/src/session/__tests__/SessionManager.test.js +593 -0
  38. package/dist/src/session/__tests__/atomic-operations.test.js +346 -0
  39. package/dist/src/session/__tests__/file-watcher.test.js +311 -0
  40. package/dist/src/session/atomic-operations.js +307 -0
  41. package/dist/src/session/file-watcher.js +227 -0
  42. package/dist/src/session/index.js +7 -0
  43. package/dist/src/session/types.js +20 -0
  44. package/dist/src/session/utils.js +180 -0
  45. package/dist/src/tui/__tests__/session-watcher.test.js +368 -0
  46. package/dist/src/tui/components/AnimatedGradient.js +45 -0
  47. package/dist/src/tui/components/ConfirmationDialog.js +89 -0
  48. package/dist/src/tui/components/CustomInput.js +14 -0
  49. package/dist/src/tui/components/Footer.js +55 -0
  50. package/dist/src/tui/components/Header.js +35 -0
  51. package/dist/src/tui/components/MultiLineTextInput.js +65 -0
  52. package/dist/src/tui/components/OptionsList.js +115 -0
  53. package/dist/src/tui/components/QuestionDisplay.js +36 -0
  54. package/dist/src/tui/components/ReviewScreen.js +57 -0
  55. package/dist/src/tui/components/SessionSelectionMenu.js +151 -0
  56. package/dist/src/tui/components/StepperView.js +166 -0
  57. package/dist/src/tui/components/TabBar.js +42 -0
  58. package/dist/src/tui/components/Toast.js +19 -0
  59. package/dist/src/tui/components/WaitingScreen.js +20 -0
  60. package/dist/src/tui/session-watcher.js +195 -0
  61. package/dist/src/tui/theme.js +114 -0
  62. package/dist/src/tui/utils/gradientText.js +24 -0
  63. package/dist/tui/__tests__/session-watcher.test.js +368 -0
  64. package/dist/tui/session-watcher.js +183 -0
  65. package/package.json +78 -0
  66. package/scripts/postinstall.cjs +51 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Integration tests for complete session workflow
3
+ */
4
+ import { promises as fs } from "fs";
5
+ import { join } from "path";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { SessionManager } from "../SessionManager.js";
8
+ import { SESSION_FILES } from "../types.js";
9
+ describe("Complete Session Workflow Integration", () => {
10
+ const testDir = "/tmp/auq-workflow-test";
11
+ const sessionManager = new SessionManager({ baseDir: testDir });
12
+ const sampleQuestions = [
13
+ {
14
+ prompt: "What is your preferred development environment?",
15
+ options: [
16
+ { label: "VS Code", description: "Microsoft's popular editor" },
17
+ { label: "Vim", description: "Classic text editor" },
18
+ { label: "JetBrains IDE", description: "Professional IDE suite" },
19
+ ],
20
+ },
21
+ {
22
+ prompt: "How do you handle version control?",
23
+ options: [
24
+ { label: "Git", description: "Distributed version control" },
25
+ { label: "SVN", description: "Centralized version control" },
26
+ { label: "Other", description: "Different system" },
27
+ ],
28
+ },
29
+ ];
30
+ beforeEach(async () => {
31
+ // Clean up test directory before each test
32
+ await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
33
+ await sessionManager.initialize();
34
+ });
35
+ afterEach(async () => {
36
+ // Clean up test directory after each test
37
+ await fs.rm(testDir, { force: true, recursive: true }).catch(() => { });
38
+ });
39
+ describe("completeSessionWorkflow", () => {
40
+ it("should execute complete workflow from creation to formatted response", async () => {
41
+ // Start the workflow in background
42
+ const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
43
+ // Give it a moment to start and create the session
44
+ await new Promise(resolve => setTimeout(resolve, 100));
45
+ // Get the session ID that was created
46
+ const sessionIds = await sessionManager.getAllSessionIds();
47
+ expect(sessionIds.length).toBe(1);
48
+ const sessionId = sessionIds[0];
49
+ // Simulate user answers
50
+ const userAnswers = {
51
+ sessionId,
52
+ timestamp: new Date().toISOString(),
53
+ answers: [
54
+ {
55
+ questionIndex: 0,
56
+ selectedOption: "VS Code",
57
+ timestamp: new Date().toISOString(),
58
+ },
59
+ {
60
+ questionIndex: 1,
61
+ selectedOption: "Git",
62
+ timestamp: new Date().toISOString(),
63
+ },
64
+ ],
65
+ };
66
+ // Save answers to trigger workflow completion
67
+ const sessionDir = sessionManager.getSessionDir(sessionId);
68
+ await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
69
+ // Wait for workflow to complete
70
+ const result = await workflowPromise;
71
+ // Verify the result structure
72
+ expect(result).toHaveProperty("formatted_response");
73
+ expect(result).toHaveProperty("sessionId");
74
+ expect(typeof result.formatted_response).toBe("string");
75
+ expect(typeof result.sessionId).toBe("string");
76
+ // Verify the formatted response content
77
+ expect(result.formatted_response).toContain("Here are the user's answers:");
78
+ expect(result.formatted_response).toContain("1. What is your preferred development environment?");
79
+ expect(result.formatted_response).toContain("2. How do you handle version control?");
80
+ // Verify session was created properly
81
+ const sessionExists = await sessionManager.sessionExists(result.sessionId);
82
+ expect(sessionExists).toBe(true);
83
+ // Verify session status is completed
84
+ const sessionStatus = await sessionManager.getSessionStatus(result.sessionId);
85
+ expect(sessionStatus?.status).toBe("completed");
86
+ }, 15000); // Longer timeout for workflow test
87
+ it("should handle workflow with custom answers", async () => {
88
+ // Start the workflow in background
89
+ const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
90
+ // Give it a moment to start and create the session
91
+ await new Promise(resolve => setTimeout(resolve, 100));
92
+ // Get the session ID that was created
93
+ const sessionIds = await sessionManager.getAllSessionIds();
94
+ expect(sessionIds.length).toBe(1);
95
+ const sessionId = sessionIds[0];
96
+ // Simulate user answers with custom text
97
+ const userAnswers = {
98
+ sessionId,
99
+ timestamp: new Date().toISOString(),
100
+ answers: [
101
+ {
102
+ questionIndex: 0,
103
+ selectedOption: "VS Code",
104
+ timestamp: new Date().toISOString(),
105
+ },
106
+ {
107
+ questionIndex: 1,
108
+ customText: "I use Git with custom aliases and hooks",
109
+ timestamp: new Date().toISOString(),
110
+ },
111
+ ],
112
+ };
113
+ // Save answers to trigger workflow completion
114
+ const sessionDir = sessionManager.getSessionDir(sessionId);
115
+ await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
116
+ // Wait for workflow to complete
117
+ const result = await workflowPromise;
118
+ // Verify the formatted response includes custom answer
119
+ expect(result.formatted_response).toContain("→ VS Code — Microsoft's popular editor");
120
+ expect(result.formatted_response).toContain("→ Other: 'I use Git with custom aliases and hooks'");
121
+ }, 15000);
122
+ it("should handle workflow with timeout", async () => {
123
+ const timeoutMs = 1000; // 1 second timeout
124
+ try {
125
+ // Start workflow but don't provide answers - should timeout
126
+ const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions, timeoutMs);
127
+ await workflowPromise;
128
+ expect.fail("Should have timed out");
129
+ }
130
+ catch (error) {
131
+ expect(error).toBeInstanceOf(Error);
132
+ expect(error.message).toContain("Timeout");
133
+ }
134
+ }, 3000);
135
+ it("should handle empty questions array", async () => {
136
+ try {
137
+ await sessionManager.completeSessionWorkflow([]);
138
+ expect.fail("Should have thrown error for empty questions");
139
+ }
140
+ catch (error) {
141
+ expect(error).toBeInstanceOf(Error);
142
+ expect(error.message).toContain("At least one question is required");
143
+ }
144
+ });
145
+ it("should update session status throughout workflow", async () => {
146
+ const sessionId = await sessionManager.createSession(sampleQuestions);
147
+ // Initial status should be pending
148
+ let status = await sessionManager.getSessionStatus(sessionId);
149
+ expect(status?.status).toBe("pending");
150
+ // After workflow completion, status should be completed
151
+ // Note: This test might be timing-sensitive since we can't easily test intermediate states
152
+ // without more complex mocking
153
+ });
154
+ });
155
+ describe("Session State Transitions", () => {
156
+ it("should properly transition through all states", async () => {
157
+ // Start workflow in background
158
+ const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
159
+ // Give it a moment to start and create the session
160
+ await new Promise(resolve => setTimeout(resolve, 100));
161
+ // Get session ID by checking existing sessions
162
+ const sessionIds = await sessionManager.getAllSessionIds();
163
+ expect(sessionIds.length).toBe(1);
164
+ const sessionId = sessionIds[0];
165
+ // Should be in-progress or processing state
166
+ const status = await sessionManager.getSessionStatus(sessionId);
167
+ expect(["in-progress", "processing"]).toContain(status?.status || "");
168
+ // Now complete the workflow by providing answers
169
+ const sessionDir = sessionManager.getSessionDir(sessionId);
170
+ const userAnswers = {
171
+ sessionId,
172
+ timestamp: new Date().toISOString(),
173
+ answers: [
174
+ {
175
+ questionIndex: 0,
176
+ selectedOption: "JetBrains IDE",
177
+ timestamp: new Date().toISOString(),
178
+ },
179
+ {
180
+ questionIndex: 1,
181
+ selectedOption: "Git",
182
+ timestamp: new Date().toISOString(),
183
+ },
184
+ ],
185
+ };
186
+ await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
187
+ // Wait for workflow to complete
188
+ const result = await workflowPromise;
189
+ // Final status should be completed
190
+ const finalStatus = await sessionManager.getSessionStatus(sessionId);
191
+ expect(finalStatus?.status).toBe("completed");
192
+ expect(result.sessionId).toBe(sessionId);
193
+ }, 10000);
194
+ it("should handle workflow failure and mark session as abandoned", async () => {
195
+ // Start workflow in background
196
+ const workflowPromise = sessionManager.completeSessionWorkflow(sampleQuestions);
197
+ // Give it a moment to start and create the session
198
+ await new Promise(resolve => setTimeout(resolve, 100));
199
+ // Get the session ID that was created
200
+ const sessionIds = await sessionManager.getAllSessionIds();
201
+ expect(sessionIds.length).toBe(1);
202
+ const sessionId = sessionIds[0];
203
+ // Simulate corrupted answers file
204
+ const sessionDir = sessionManager.getSessionDir(sessionId);
205
+ await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), "invalid json content");
206
+ // Give file system time to register
207
+ await new Promise(resolve => setTimeout(resolve, 100));
208
+ try {
209
+ // This should fail due to invalid JSON
210
+ await workflowPromise;
211
+ expect.fail("Should have failed due to invalid answers");
212
+ }
213
+ catch (error) {
214
+ // Session should be marked as abandoned
215
+ const status = await sessionManager.getSessionStatus(sessionId);
216
+ expect(status?.status).toBe("abandoned");
217
+ expect(status?.error).toBeTruthy();
218
+ }
219
+ });
220
+ });
221
+ describe("Session Metadata and Debugging", () => {
222
+ it("should provide enhanced session metadata", async () => {
223
+ const sessionId = await sessionManager.createSession(sampleQuestions);
224
+ // Get enhanced metadata
225
+ const metadata = await sessionManager.getSessionStatusWithMetadata(sessionId);
226
+ expect(metadata).toHaveProperty("status");
227
+ expect(metadata).toHaveProperty("request");
228
+ expect(metadata.status?.sessionId).toBe(sessionId);
229
+ expect(metadata.request?.questions).toEqual(sampleQuestions);
230
+ // Answer should be undefined initially (no answers file exists yet)
231
+ expect(metadata.answer).toBeUndefined();
232
+ expect(metadata.formattedResponse).toBeUndefined();
233
+ });
234
+ it("should include formatted response when answers exist", async () => {
235
+ // Create session and provide answers
236
+ const sessionId = await sessionManager.createSession(sampleQuestions);
237
+ const sessionDir = sessionManager.getSessionDir(sessionId);
238
+ const userAnswers = {
239
+ sessionId,
240
+ timestamp: new Date().toISOString(),
241
+ answers: [
242
+ {
243
+ questionIndex: 0,
244
+ selectedOption: "Vim",
245
+ timestamp: new Date().toISOString(),
246
+ },
247
+ {
248
+ questionIndex: 1,
249
+ selectedOption: "Git",
250
+ timestamp: new Date().toISOString(),
251
+ },
252
+ ],
253
+ };
254
+ await fs.writeFile(join(sessionDir, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers, null, 2));
255
+ // Get enhanced metadata
256
+ const metadata = await sessionManager.getSessionStatusWithMetadata(sessionId);
257
+ expect(metadata.answer).toBeTruthy();
258
+ expect(metadata.formattedResponse).toBeTruthy();
259
+ expect(metadata.formattedResponse).toContain("→ Vim — Classic text editor");
260
+ expect(metadata.formattedResponse).toContain("→ Git — Distributed version control");
261
+ });
262
+ });
263
+ describe("Error Recovery and Edge Cases", () => {
264
+ it("should handle multiple session workflows", async () => {
265
+ // Note: Testing truly concurrent workflows reveals a shared resource issue with fileWatcher
266
+ // For now, test sequential workflows which should work fine
267
+ // First workflow
268
+ const workflow1Promise = sessionManager.completeSessionWorkflow(sampleQuestions);
269
+ // Wait for first session to be created
270
+ await new Promise(resolve => setTimeout(resolve, 100));
271
+ let sessionIds = await sessionManager.getAllSessionIds();
272
+ expect(sessionIds.length).toBe(1);
273
+ const sessionId1 = sessionIds[0];
274
+ // Complete first workflow
275
+ const sessionDir1 = sessionManager.getSessionDir(sessionId1);
276
+ const userAnswers1 = {
277
+ sessionId: sessionId1,
278
+ timestamp: new Date().toISOString(),
279
+ answers: [
280
+ {
281
+ questionIndex: 0,
282
+ selectedOption: "VS Code",
283
+ timestamp: new Date().toISOString(),
284
+ },
285
+ {
286
+ questionIndex: 1,
287
+ selectedOption: "Git",
288
+ timestamp: new Date().toISOString(),
289
+ },
290
+ ],
291
+ };
292
+ await fs.writeFile(join(sessionDir1, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers1, null, 2));
293
+ const result1 = await workflow1Promise;
294
+ // Second workflow (after first completes)
295
+ const workflow2Promise = sessionManager.completeSessionWorkflow(sampleQuestions);
296
+ // Wait for second session to be created
297
+ await new Promise(resolve => setTimeout(resolve, 100));
298
+ sessionIds = await sessionManager.getAllSessionIds();
299
+ expect(sessionIds.length).toBe(2);
300
+ const sessionId2 = sessionIds.find(id => id !== sessionId1);
301
+ // Complete second workflow
302
+ const sessionDir2 = sessionManager.getSessionDir(sessionId2);
303
+ const userAnswers2 = {
304
+ sessionId: sessionId2,
305
+ timestamp: new Date().toISOString(),
306
+ answers: [
307
+ {
308
+ questionIndex: 0,
309
+ selectedOption: "JetBrains IDE",
310
+ timestamp: new Date().toISOString(),
311
+ },
312
+ {
313
+ questionIndex: 1,
314
+ selectedOption: "SVN",
315
+ timestamp: new Date().toISOString(),
316
+ },
317
+ ],
318
+ };
319
+ await fs.writeFile(join(sessionDir2, SESSION_FILES.ANSWERS), JSON.stringify(userAnswers2, null, 2));
320
+ const result2 = await workflow2Promise;
321
+ // Verify both workflows completed successfully with different results
322
+ expect(result1.sessionId).not.toBe(result2.sessionId);
323
+ expect(result1.formatted_response).toContain("→ VS Code");
324
+ expect(result1.formatted_response).toContain("→ Git");
325
+ expect(result2.formatted_response).toContain("→ JetBrains IDE");
326
+ expect(result2.formatted_response).toContain("→ SVN");
327
+ // Both sessions should be marked as completed
328
+ const status1 = await sessionManager.getSessionStatus(result1.sessionId);
329
+ const status2 = await sessionManager.getSessionStatus(result2.sessionId);
330
+ expect(status1?.status).toBe("completed");
331
+ expect(status2?.status).toBe("completed");
332
+ }, 15000);
333
+ });
334
+ });
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Atomic file operations for session management
3
+ * Provides truly atomic read/write operations to prevent data corruption
4
+ */
5
+ import { constants, copyFile, rename } from "fs";
6
+ import { promises as fs } from "fs";
7
+ import { tmpdir } from "os";
8
+ import { join } from "path";
9
+ import { fileExists } from "./utils.js";
10
+ /**
11
+ * Error types for atomic operations
12
+ */
13
+ export class AtomicOperationError extends Error {
14
+ operation;
15
+ filePath;
16
+ cause;
17
+ constructor(message, operation, filePath, cause) {
18
+ super(message);
19
+ this.operation = operation;
20
+ this.filePath = filePath;
21
+ this.cause = cause;
22
+ this.name = "AtomicOperationError";
23
+ }
24
+ }
25
+ export class AtomicReadError extends AtomicOperationError {
26
+ constructor(filePath, cause) {
27
+ super(`Atomic read failed: ${filePath}`, "read", filePath, cause);
28
+ this.name = "AtomicReadError";
29
+ }
30
+ }
31
+ export class AtomicWriteError extends AtomicOperationError {
32
+ constructor(filePath, cause) {
33
+ super(`Atomic write failed: ${filePath}`, "write", filePath, cause);
34
+ this.name = "AtomicWriteError";
35
+ }
36
+ }
37
+ export class FileLockError extends AtomicOperationError {
38
+ constructor(filePath, cause) {
39
+ super(`Failed to acquire file lock: ${filePath}`, "write", filePath, cause);
40
+ this.name = "FileLockError";
41
+ }
42
+ }
43
+ const DEFAULT_WRITE_OPTIONS = {
44
+ encoding: "utf8",
45
+ flag: "w",
46
+ maxRetries: 3,
47
+ mode: 0o600,
48
+ retryDelay: 100,
49
+ tmpDir: tmpdir(),
50
+ };
51
+ const DEFAULT_READ_OPTIONS = {
52
+ encoding: "utf8",
53
+ flag: "r",
54
+ maxRetries: 3,
55
+ retryDelay: 100,
56
+ };
57
+ /**
58
+ * Atomic file copy operation
59
+ */
60
+ export async function atomicCopyFile(sourcePath, destPath, options = {}) {
61
+ const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
62
+ try {
63
+ // Acquire destination lock
64
+ await acquireLock(destPath);
65
+ // Perform atomic copy
66
+ await new Promise((resolve, reject) => {
67
+ copyFile(sourcePath, destPath, constants.COPYFILE_EXCL, (error) => {
68
+ if (error) {
69
+ reject(error);
70
+ }
71
+ else {
72
+ resolve();
73
+ }
74
+ });
75
+ });
76
+ // Set correct permissions on destination
77
+ await fs.chmod(destPath, opts.mode);
78
+ }
79
+ catch (error) {
80
+ throw new AtomicOperationError(`Atomic copy failed: ${sourcePath} -> ${destPath}`, "write", destPath, error);
81
+ }
82
+ finally {
83
+ await releaseLock(destPath);
84
+ }
85
+ }
86
+ /**
87
+ * Atomic file delete operation
88
+ */
89
+ export async function atomicDeleteFile(filePath) {
90
+ try {
91
+ await acquireLock(filePath);
92
+ await fs.unlink(filePath);
93
+ }
94
+ catch (error) {
95
+ if (error.code === "ENOENT") {
96
+ // File doesn't exist, that's okay for delete
97
+ return;
98
+ }
99
+ throw new AtomicOperationError(`Atomic delete failed: ${filePath}`, "write", filePath, error);
100
+ }
101
+ finally {
102
+ await releaseLock(filePath);
103
+ }
104
+ }
105
+ /**
106
+ * Atomic read operation with retry logic
107
+ */
108
+ export async function atomicReadFile(filePath, options = {}) {
109
+ const opts = { ...DEFAULT_READ_OPTIONS, ...options };
110
+ let lastError = null;
111
+ for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
112
+ try {
113
+ // Check if file exists
114
+ if (!(await fileExists(filePath))) {
115
+ throw new AtomicReadError(filePath, new Error("File does not exist"));
116
+ }
117
+ // Acquire read lock (shared lock)
118
+ await acquireLock(filePath);
119
+ try {
120
+ const data = await fs.readFile(filePath, {
121
+ encoding: opts.encoding,
122
+ flag: opts.flag,
123
+ });
124
+ return data;
125
+ }
126
+ finally {
127
+ await releaseLock(filePath);
128
+ }
129
+ }
130
+ catch (error) {
131
+ lastError = error;
132
+ // Don't retry on certain errors
133
+ if (error instanceof AtomicReadError ||
134
+ error.code === "ENOENT" ||
135
+ error.code === "EACCES") {
136
+ break;
137
+ }
138
+ // Wait before retrying
139
+ if (attempt < opts.maxRetries - 1) {
140
+ await new Promise((resolve) => setTimeout(resolve, opts.retryDelay * Math.pow(2, attempt)));
141
+ }
142
+ }
143
+ }
144
+ throw new AtomicReadError(filePath, lastError || new Error("Unknown error"));
145
+ }
146
+ /**
147
+ * Atomic write operation using temporary file and rename
148
+ */
149
+ export async function atomicWriteFile(filePath, data, options = {}) {
150
+ const opts = { ...DEFAULT_WRITE_OPTIONS, ...options };
151
+ const tempPath = generateTempPath(filePath, opts.tmpDir);
152
+ try {
153
+ // Acquire file lock
154
+ await acquireLock(filePath);
155
+ // Write to temporary file first
156
+ await fs.writeFile(tempPath, data, {
157
+ encoding: opts.encoding,
158
+ flag: opts.flag,
159
+ mode: opts.mode,
160
+ });
161
+ // Verify the temporary file was written correctly
162
+ const verificationData = await fs.readFile(tempPath, opts.encoding);
163
+ if (verificationData !== data) {
164
+ throw new AtomicWriteError(filePath, new Error("Data verification failed after write"));
165
+ }
166
+ // Atomic rename operation
167
+ await new Promise((resolve, reject) => {
168
+ rename(tempPath, filePath, (error) => {
169
+ if (error) {
170
+ reject(error);
171
+ }
172
+ else {
173
+ resolve();
174
+ }
175
+ });
176
+ });
177
+ // Verify the final file exists and has correct permissions
178
+ try {
179
+ await fs.access(filePath, constants.F_OK);
180
+ const stats = await fs.stat(filePath);
181
+ // Check if mode is correct (at least read/write for owner)
182
+ if ((stats.mode & 0o600) !== 0o600) {
183
+ await fs.chmod(filePath, opts.mode);
184
+ }
185
+ }
186
+ catch (error) {
187
+ throw new AtomicWriteError(filePath, new Error(`File verification failed after rename: ${error}`));
188
+ }
189
+ }
190
+ catch (error) {
191
+ // Clean up temporary file on failure
192
+ try {
193
+ await fs.unlink(tempPath);
194
+ }
195
+ catch {
196
+ // Ignore cleanup errors
197
+ }
198
+ throw new AtomicWriteError(filePath, error);
199
+ }
200
+ finally {
201
+ // Always release the lock
202
+ await releaseLock(filePath);
203
+ }
204
+ }
205
+ /**
206
+ * Check if a file is locked
207
+ */
208
+ export async function isFileLocked(filePath) {
209
+ const lockPath = `${filePath}.lock`;
210
+ return await fileExists(lockPath);
211
+ }
212
+ /**
213
+ * Wait for a file to become unlocked (with timeout)
214
+ */
215
+ export async function waitForFileUnlock(filePath, timeout = 10000) {
216
+ const startTime = Date.now();
217
+ while (Date.now() - startTime < timeout) {
218
+ if (!(await isFileLocked(filePath))) {
219
+ return;
220
+ }
221
+ await new Promise((resolve) => setTimeout(resolve, 100));
222
+ }
223
+ throw new FileLockError(filePath, new Error("Timeout waiting for file unlock"));
224
+ }
225
+ /**
226
+ * Simple file lock implementation using lock files
227
+ */
228
+ async function acquireLock(filePath, timeout = 5000) {
229
+ const lockPath = `${filePath}.lock`;
230
+ const startTime = Date.now();
231
+ while (Date.now() - startTime < timeout) {
232
+ try {
233
+ // Try to create lock file (O_EXCL ensures atomic creation)
234
+ await fs.writeFile(lockPath, process.pid.toString(), {
235
+ encoding: "utf8",
236
+ flag: "wx", // Write and fail if file exists
237
+ mode: 0o600,
238
+ });
239
+ return; // Lock acquired
240
+ }
241
+ catch (error) {
242
+ if (error.code === "EEXIST") {
243
+ // Lock file exists, check if it's stale
244
+ try {
245
+ const lockContent = await fs.readFile(lockPath, "utf8");
246
+ const lockPid = parseInt(lockContent.trim(), 10);
247
+ // Check if process with PID still exists
248
+ try {
249
+ process.kill(lockPid, 0); // Signal 0 just checks if process exists
250
+ }
251
+ catch {
252
+ // Process doesn't exist, remove stale lock
253
+ await fs.unlink(lockPath);
254
+ continue;
255
+ }
256
+ }
257
+ catch {
258
+ // Can't read lock file, try to remove it
259
+ try {
260
+ await fs.unlink(lockPath);
261
+ continue;
262
+ }
263
+ catch {
264
+ // Can't remove lock file, continue waiting
265
+ }
266
+ }
267
+ // Lock is active, wait and retry
268
+ await new Promise((resolve) => setTimeout(resolve, 50));
269
+ continue;
270
+ }
271
+ throw error; // Other error occurred
272
+ }
273
+ }
274
+ throw new FileLockError(filePath, new Error("Lock acquisition timeout"));
275
+ }
276
+ /**
277
+ * Get the base filename from a path
278
+ */
279
+ function basename(path) {
280
+ return path.split(/[\\/]/).pop() || "";
281
+ }
282
+ /**
283
+ * Generate a unique temporary file path
284
+ */
285
+ function generateTempPath(originalPath, tmpDir) {
286
+ const timestamp = Date.now();
287
+ const random = Math.random().toString(36).substring(2);
288
+ const filename = `${timestamp}-${random}-${basename(originalPath)}.tmp`;
289
+ return join(tmpDir, filename);
290
+ }
291
+ /**
292
+ * Release a file lock
293
+ */
294
+ async function releaseLock(filePath) {
295
+ const lockPath = `${filePath}.lock`;
296
+ try {
297
+ await fs.unlink(lockPath);
298
+ }
299
+ catch (error) {
300
+ // Lock file might not exist or we don't have permission
301
+ // Don't throw error as this is cleanup
302
+ // Only log if it's not ENOENT (file not found)
303
+ if (error.code !== "ENOENT") {
304
+ console.warn(`Warning: Could not release lock for ${filePath}:`, error);
305
+ }
306
+ }
307
+ }