claude-comms 1.0.0 → 1.0.2

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,648 @@
1
+ /**
2
+ * File writer module for installing Claude setup files
3
+ * Handles file writing, conflict resolution, backup, and rollback
4
+ */
5
+
6
+ const fs = require('fs').promises;
7
+ const path = require('path');
8
+ const { EventEmitter } = require('events');
9
+ const {
10
+ ValidationError,
11
+ ErrorFactory
12
+ } = require('../utils/errors');
13
+ const platformUtils = require('../utils/platform');
14
+
15
+ /**
16
+ * File Writer class implementing WriterAPI interface
17
+ */
18
+ class FileWriter extends EventEmitter {
19
+ constructor(options = {}) {
20
+ super();
21
+
22
+ this.options = {
23
+ backupDir: options.backupDir || null, // null = same directory as original
24
+ maxRetries: options.maxRetries || 3,
25
+ retryDelay: options.retryDelay || 1000,
26
+ batchSize: options.batchSize || 50,
27
+ streaming: options.streaming || false,
28
+ progressReporter: options.progressReporter || null,
29
+ platformUtils: options.platformUtils || platformUtils,
30
+ ...options
31
+ };
32
+
33
+ this.rollbackStack = [];
34
+ this.activeOperations = new Map();
35
+ }
36
+
37
+ /**
38
+ * Write a single file with options for backup, permissions, etc.
39
+ * Implements WriterAPI.writeFile interface
40
+ */
41
+ async writeFile(filePath, content, options = {}) {
42
+ const opts = { ...this.options, ...options };
43
+
44
+ // Validate inputs
45
+ this.validatePath(filePath, opts.targetDir);
46
+
47
+ if (opts.dryRun) {
48
+ return { path: filePath, dryRun: true, size: Buffer.byteLength(content) };
49
+ }
50
+
51
+ // Emit progress start
52
+ if (opts.progressReporter) {
53
+ opts.progressReporter.start('Writing file', 1);
54
+ }
55
+
56
+ try {
57
+ const absolutePath = this.resolvePath(filePath, opts.targetDir);
58
+ const dirPath = path.dirname(absolutePath);
59
+
60
+ // Ensure directory exists
61
+ await this.ensureDirectory(dirPath);
62
+
63
+ // Check if file exists and handle backup
64
+ let backupPath = null;
65
+ const fileExists = await this.fileExists(absolutePath);
66
+
67
+ if (fileExists && opts.backup) {
68
+ backupPath = await this.backup(absolutePath);
69
+ this.rollbackStack.push({ type: 'restore', backupPath, originalPath: absolutePath });
70
+ } else if (fileExists && !opts.overwrite) {
71
+ if (opts.progressReporter) {
72
+ opts.progressReporter.complete('File skipped (already exists)');
73
+ }
74
+ return { path: filePath, skipped: true, reason: 'exists' };
75
+ }
76
+
77
+ // Write file atomically
78
+ await this.writeFileAtomic(absolutePath, content, opts);
79
+
80
+ // Track for potential rollback
81
+ if (!fileExists) {
82
+ this.rollbackStack.push({ type: 'delete', path: absolutePath });
83
+ }
84
+
85
+ if (opts.progressReporter) {
86
+ opts.progressReporter.update(1, `Wrote ${path.basename(absolutePath)}`);
87
+ opts.progressReporter.complete('File written successfully');
88
+ }
89
+
90
+ return {
91
+ path: filePath,
92
+ absolutePath,
93
+ size: Buffer.byteLength(content),
94
+ backup: backupPath
95
+ };
96
+
97
+ } catch (error) {
98
+ if (opts.progressReporter) {
99
+ opts.progressReporter.fail(error);
100
+ }
101
+ throw ErrorFactory.fromCaught(error, `writing file ${filePath}`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Write multiple files to a directory
107
+ * Implements WriterAPI.writeDirectory interface
108
+ */
109
+ async writeDirectory(files, options = {}) {
110
+ const opts = { ...this.options, ...options };
111
+ const targetDir = opts.targetDir;
112
+
113
+ if (!targetDir) {
114
+ throw new ValidationError('targetDir is required for writeDirectory', 'targetDir');
115
+ }
116
+
117
+ // Validate files array
118
+ if (!Array.isArray(files)) {
119
+ throw new ValidationError('files must be an array', 'files');
120
+ }
121
+
122
+ const result = {
123
+ written: [],
124
+ skipped: [],
125
+ backed_up: new Map(),
126
+ errors: [],
127
+ directoriesCreated: []
128
+ };
129
+
130
+ // Emit progress start
131
+ if (opts.progressReporter) {
132
+ opts.progressReporter.start('Writing files', files.length);
133
+ }
134
+
135
+ // Pre-flight validation
136
+ if (opts.atomic) {
137
+ this.createCheckpoint();
138
+ }
139
+
140
+ try {
141
+ // Process files in batches to manage memory
142
+ const batchSize = opts.batchSize || 50;
143
+
144
+ for (let i = 0; i < files.length; i += batchSize) {
145
+ const batch = files.slice(i, i + batchSize);
146
+
147
+ if (opts.atomic) {
148
+ // For atomic operations, process sequentially
149
+ for (const file of batch) {
150
+ await this.processFileForDirectory(file, targetDir, opts, result, i + batch.indexOf(file) + 1);
151
+ }
152
+ } else {
153
+ // For non-atomic, can process in parallel
154
+ await Promise.all(
155
+ batch.map((file, idx) =>
156
+ this.processFileForDirectory(file, targetDir, opts, result, i + idx + 1)
157
+ .catch(error => {
158
+ result.errors.push({ path: file.path, error });
159
+ })
160
+ )
161
+ );
162
+ }
163
+ }
164
+
165
+ if (opts.progressReporter) {
166
+ opts.progressReporter.complete(`Wrote ${result.written.length} files successfully`);
167
+ }
168
+
169
+ // Log installation summary for debugging
170
+ this.emit('installationSummary', {
171
+ filesWritten: result.written.length,
172
+ filesSkipped: result.skipped.length,
173
+ directoriesCreated: result.directoriesCreated.length,
174
+ errors: result.errors.length
175
+ });
176
+
177
+ return result;
178
+
179
+ } catch (error) {
180
+ if (opts.atomic) {
181
+ // Rollback all changes
182
+ await this.rollbackToCheckpoint();
183
+ }
184
+
185
+ if (opts.progressReporter) {
186
+ opts.progressReporter.fail(error);
187
+ }
188
+
189
+ throw ErrorFactory.fromCaught(error, 'writing directory');
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Create a backup of a file or directory
195
+ * Implements WriterAPI.backup interface
196
+ */
197
+ async backup(sourcePath, options = {}) {
198
+ const opts = { ...this.options, ...options };
199
+
200
+ try {
201
+ const absoluteSource = this.resolvePath(sourcePath);
202
+
203
+ // Check if source exists
204
+ const stats = await fs.stat(absoluteSource);
205
+
206
+ // Generate backup path with timestamp
207
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
208
+ const sourceName = path.basename(absoluteSource);
209
+ const backupName = `${sourceName}.backup.${timestamp}`;
210
+
211
+ const backupDir = opts.backupDir || path.dirname(absoluteSource);
212
+ const backupPath = path.join(backupDir, backupName);
213
+
214
+ // Ensure backup directory exists
215
+ await this.ensureDirectory(path.dirname(backupPath));
216
+
217
+ if (stats.isDirectory()) {
218
+ // Recursive directory copy
219
+ await this.copyDirectory(absoluteSource, backupPath);
220
+ } else {
221
+ // File copy with permission preservation
222
+ await fs.copyFile(absoluteSource, backupPath);
223
+ await fs.chmod(backupPath, stats.mode);
224
+ }
225
+
226
+ return backupPath;
227
+
228
+ } catch (error) {
229
+ throw ErrorFactory.fromCaught(error, `backing up ${sourcePath}`);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Restore from backup
235
+ * Implements WriterAPI.rollback interface
236
+ */
237
+ async rollback(backupPath, _options = {}) {
238
+ try {
239
+ const absoluteBackup = this.resolvePath(backupPath);
240
+
241
+ // Extract original path from backup name
242
+ const backupName = path.basename(absoluteBackup);
243
+ const match = backupName.match(/(.+)\.backup\.([\d-T]+.*?)$/);
244
+
245
+ if (!match) {
246
+ throw new ValidationError('Invalid backup file name format', 'backupPath');
247
+ }
248
+
249
+ const originalName = match[1];
250
+ const originalPath = path.join(path.dirname(absoluteBackup), originalName);
251
+
252
+ const backupStats = await fs.stat(absoluteBackup);
253
+
254
+ if (backupStats.isDirectory()) {
255
+ // Remove current directory and restore backup
256
+ try {
257
+ await fs.rm(originalPath, { recursive: true, force: true });
258
+ } catch (error) {
259
+ // Directory might not exist, which is OK
260
+ }
261
+ await this.copyDirectory(absoluteBackup, originalPath);
262
+ } else {
263
+ // Restore file
264
+ await fs.copyFile(absoluteBackup, originalPath);
265
+ await fs.chmod(originalPath, backupStats.mode);
266
+ }
267
+
268
+ // Clean up backup file
269
+ await fs.rm(absoluteBackup, { recursive: true, force: true });
270
+
271
+ return originalPath;
272
+
273
+ } catch (error) {
274
+ throw ErrorFactory.fromCaught(error, `rolling back from ${backupPath}`);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Validate target directory and permissions
280
+ */
281
+ async validateTarget(targetDir) {
282
+ try {
283
+ const absoluteTarget = this.resolvePath(targetDir);
284
+
285
+ // Check if target exists
286
+ const exists = await this.fileExists(absoluteTarget);
287
+
288
+ if (exists) {
289
+ const stats = await fs.stat(absoluteTarget);
290
+ if (!stats.isDirectory()) {
291
+ return false; // Target exists but is not a directory
292
+ }
293
+ }
294
+
295
+ // Test write permissions
296
+ return await this.options.platformUtils.isWritable(absoluteTarget);
297
+
298
+ } catch (error) {
299
+ return false;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Validate that installation was complete by checking expected structure
305
+ */
306
+ async validateInstallationCompleteness(targetDir, expectedFiles = []) {
307
+ const issues = [];
308
+
309
+ // Check for essential .claude structure
310
+ const essentialPaths = [
311
+ '.claude',
312
+ '.claude/settings.json',
313
+ '.claude/hooks',
314
+ '.claude/hooks/comms',
315
+ '.claude/agents',
316
+ '.claude/agents/core'
317
+ ];
318
+
319
+ for (const relativePath of essentialPaths) {
320
+ const fullPath = path.join(targetDir, relativePath);
321
+ const exists = await this.fileExists(fullPath);
322
+ if (!exists) {
323
+ issues.push({
324
+ type: 'missing_essential',
325
+ path: relativePath,
326
+ message: `Essential path missing: ${relativePath}`
327
+ });
328
+ }
329
+ }
330
+
331
+ // Check expected files if provided
332
+ for (const expectedFile of expectedFiles) {
333
+ const fullPath = path.join(targetDir, expectedFile);
334
+ const exists = await this.fileExists(fullPath);
335
+ if (!exists) {
336
+ issues.push({
337
+ type: 'missing_expected',
338
+ path: expectedFile,
339
+ message: `Expected file missing: ${expectedFile}`
340
+ });
341
+ }
342
+ }
343
+
344
+ return {
345
+ complete: issues.length === 0,
346
+ issues
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Check available disk space
352
+ */
353
+ async checkDiskSpace(targetDir, requiredBytes) {
354
+ try {
355
+ const spaceInfo = await this.options.platformUtils.getDiskSpace(targetDir);
356
+ if (!spaceInfo) {
357
+ return true; // Can't determine, assume OK
358
+ }
359
+
360
+ return spaceInfo.free >= requiredBytes;
361
+
362
+ } catch (error) {
363
+ return true; // Can't determine, assume OK
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Validate file path for security
369
+ */
370
+ validatePath(filePath, basePath) {
371
+ if (!filePath || typeof filePath !== 'string') {
372
+ throw new ValidationError('File path must be a non-empty string', 'filePath');
373
+ }
374
+
375
+ if (basePath && !this.options.platformUtils.isSafePath(filePath, basePath)) {
376
+ throw new ValidationError('File path escapes target directory', 'filePath');
377
+ }
378
+
379
+ // Check for dangerous patterns
380
+ const dangerousPatterns = [
381
+ /\.\.[\\/]/, // Directory traversal
382
+ /^[\\/]/, // Absolute paths when basePath is provided
383
+ /[<>:"|*?]/ // Invalid filename characters on Windows
384
+ ];
385
+
386
+ if (basePath) {
387
+ for (const pattern of dangerousPatterns) {
388
+ if (pattern.test(filePath)) {
389
+ throw new ValidationError('File path contains invalid characters', 'filePath');
390
+ }
391
+ }
392
+ }
393
+ }
394
+
395
+ // PRIVATE HELPER METHODS
396
+
397
+ async processFileForDirectory(file, targetDir, opts, result, currentIndex) {
398
+ try {
399
+ const fileOptions = {
400
+ ...opts,
401
+ targetDir,
402
+ permissions: file.permissions,
403
+ encoding: file.encoding
404
+ };
405
+
406
+ // Track directory creation for reporting
407
+ const dirPath = path.dirname(path.join(targetDir, file.path));
408
+ if (!result.directoriesCreated.includes(dirPath)) {
409
+ result.directoriesCreated.push(dirPath);
410
+ }
411
+
412
+ const writeResult = await this.writeFile(file.path, file.content, fileOptions);
413
+
414
+ if (writeResult.skipped) {
415
+ result.skipped.push(file.path);
416
+ } else {
417
+ result.written.push(file.path);
418
+ if (writeResult.backup) {
419
+ result.backed_up.set(writeResult.absolutePath, writeResult.backup);
420
+ }
421
+ }
422
+
423
+ if (opts.progressReporter) {
424
+ opts.progressReporter.update(currentIndex, `Processed ${file.path}`);
425
+ }
426
+
427
+ } catch (error) {
428
+ result.errors.push({ path: file.path, error });
429
+
430
+ if (opts.atomic) {
431
+ throw error; // Stop processing in atomic mode
432
+ }
433
+ }
434
+ }
435
+
436
+ async writeFileAtomic(filePath, content, options) {
437
+ const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
438
+
439
+ try {
440
+ // Write to temporary file first
441
+ if (options.encoding === 'binary' || Buffer.isBuffer(content)) {
442
+ await fs.writeFile(tempPath, content);
443
+ } else {
444
+ const processedContent = options.normalizeLineEndings
445
+ ? this.options.platformUtils.convertFileContent(content)
446
+ : content;
447
+ await fs.writeFile(tempPath, processedContent, 'utf8');
448
+ }
449
+
450
+ // Set permissions if specified
451
+ if (options.permissions) {
452
+ await fs.chmod(tempPath, options.permissions);
453
+ }
454
+
455
+ // Atomic rename
456
+ await fs.rename(tempPath, filePath);
457
+
458
+ } catch (error) {
459
+ // Clean up temp file on failure
460
+ try {
461
+ await fs.unlink(tempPath);
462
+ } catch (cleanupError) {
463
+ // Ignore cleanup errors
464
+ }
465
+ throw error;
466
+ }
467
+ }
468
+
469
+ async ensureDirectory(dirPath) {
470
+ try {
471
+ await fs.mkdir(dirPath, { recursive: true });
472
+ return dirPath;
473
+ } catch (error) {
474
+ if (error.code !== 'EEXIST') {
475
+ throw error;
476
+ }
477
+ return dirPath;
478
+ }
479
+ }
480
+
481
+ async fileExists(filePath) {
482
+ try {
483
+ await fs.access(filePath);
484
+ return true;
485
+ } catch {
486
+ return false;
487
+ }
488
+ }
489
+
490
+ resolvePath(filePath, basePath = null) {
491
+ if (basePath) {
492
+ return this.options.platformUtils.getAbsolutePath(filePath, basePath);
493
+ }
494
+ return this.options.platformUtils.getAbsolutePath(filePath);
495
+ }
496
+
497
+ async copyDirectory(src, dest) {
498
+ await this.ensureDirectory(dest);
499
+ const entries = await fs.readdir(src, { withFileTypes: true });
500
+
501
+ for (const entry of entries) {
502
+ const srcPath = path.join(src, entry.name);
503
+ const destPath = path.join(dest, entry.name);
504
+
505
+ if (entry.isDirectory()) {
506
+ await this.copyDirectory(srcPath, destPath);
507
+ } else {
508
+ await fs.copyFile(srcPath, destPath);
509
+ const stats = await fs.stat(srcPath);
510
+ await fs.chmod(destPath, stats.mode);
511
+ }
512
+ }
513
+ }
514
+
515
+ createCheckpoint() {
516
+ this.rollbackStack = [];
517
+ }
518
+
519
+ async rollbackToCheckpoint() {
520
+ // Rollback in reverse order
521
+ for (let i = this.rollbackStack.length - 1; i >= 0; i--) {
522
+ const operation = this.rollbackStack[i];
523
+
524
+ try {
525
+ if (operation.type === 'delete') {
526
+ await fs.unlink(operation.path);
527
+ } else if (operation.type === 'restore') {
528
+ await this.rollback(operation.backupPath);
529
+ }
530
+ } catch (error) {
531
+ // Log but continue rollback
532
+ console.warn(`Rollback operation failed: ${error.message}`);
533
+ }
534
+ }
535
+
536
+ this.rollbackStack = [];
537
+ }
538
+
539
+ async createRollbackPoint(targetDir) {
540
+ const timestamp = Date.now();
541
+ const rollbackId = `rollback-${timestamp}`;
542
+
543
+ // Create snapshot of current state
544
+ const snapshot = {
545
+ id: rollbackId,
546
+ timestamp,
547
+ targetDir,
548
+ files: await this.snapshotDirectory(targetDir)
549
+ };
550
+
551
+ return snapshot;
552
+ }
553
+
554
+ async rollbackToPoint(rollbackInfo) {
555
+ // Restore directory to snapshot state
556
+ await this.restoreFromSnapshot(rollbackInfo);
557
+ }
558
+
559
+ async snapshotDirectory(dirPath) {
560
+ const snapshot = new Map();
561
+
562
+ try {
563
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
564
+
565
+ for (const entry of entries) {
566
+ const fullPath = path.join(dirPath, entry.name);
567
+
568
+ if (entry.isFile()) {
569
+ const content = await fs.readFile(fullPath);
570
+ const stats = await fs.stat(fullPath);
571
+ snapshot.set(fullPath, { content, mode: stats.mode, type: 'file' });
572
+ } else if (entry.isDirectory()) {
573
+ snapshot.set(fullPath, { type: 'directory' });
574
+ const subSnapshot = await this.snapshotDirectory(fullPath);
575
+ for (const [subPath, subData] of subSnapshot) {
576
+ snapshot.set(subPath, subData);
577
+ }
578
+ }
579
+ }
580
+ } catch (error) {
581
+ if (error.code !== 'ENOENT') {
582
+ throw error;
583
+ }
584
+ }
585
+
586
+ return snapshot;
587
+ }
588
+
589
+ async restoreFromSnapshot(rollbackInfo) {
590
+ const { targetDir, files } = rollbackInfo;
591
+
592
+ // Remove all current files
593
+ try {
594
+ await fs.rm(targetDir, { recursive: true, force: true });
595
+ } catch (error) {
596
+ // Directory might not exist
597
+ }
598
+
599
+ // Restore from snapshot
600
+ for (const [filePath, fileData] of files) {
601
+ if (fileData.type === 'directory') {
602
+ await this.ensureDirectory(filePath);
603
+ } else if (fileData.type === 'file') {
604
+ await this.ensureDirectory(path.dirname(filePath));
605
+ await fs.writeFile(filePath, fileData.content);
606
+ await fs.chmod(filePath, fileData.mode);
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ // Legacy function exports for backward compatibility
613
+ async function writeSetupFiles(setupFiles, targetDir, options = {}) {
614
+ const writer = new FileWriter(options);
615
+ const files = setupFiles.map(file => ({
616
+ path: file.path || file.name,
617
+ content: file.content,
618
+ encoding: file.encoding,
619
+ permissions: file.permissions
620
+ }));
621
+
622
+ return writer.writeDirectory(files, { targetDir, ...options });
623
+ }
624
+
625
+ async function backupExistingFiles(targetDir, files) {
626
+ const writer = new FileWriter();
627
+ const backups = new Map();
628
+
629
+ for (const file of files) {
630
+ const filePath = path.join(targetDir, file.path || file.name);
631
+ try {
632
+ if (await writer.fileExists(filePath)) {
633
+ const backupPath = await writer.backup(filePath);
634
+ backups.set(filePath, backupPath);
635
+ }
636
+ } catch (error) {
637
+ // Continue with other files
638
+ }
639
+ }
640
+
641
+ return backups;
642
+ }
643
+
644
+ module.exports = {
645
+ FileWriter,
646
+ writeSetupFiles,
647
+ backupExistingFiles
648
+ };