undoai 0.1.0-beta.2 → 1.0.1
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/CONTRIBUTING.md +287 -0
- package/QUICKSTART.md +90 -0
- package/USAGE.md +474 -0
- package/demo-feature/README.md +1 -0
- package/demo-feature/config.ts +1 -0
- package/demo-feature/index.ts +1 -0
- package/demo-feature/types.ts +1 -0
- package/demo-feature/user-service.ts +1 -0
- package/dist/cli/index.js +0 -0
- package/fitur_premium.md +5 -0
- package/package.json +7 -28
- package/run-all-tests.sh +30 -0
- package/src/cli/commands/restore.ts +222 -0
- package/src/cli/commands/status.ts +51 -0
- package/src/cli/commands/stop.ts +22 -0
- package/src/cli/commands/watch.ts +162 -0
- package/src/cli/index.ts +52 -0
- package/src/core/daemon.ts +104 -0
- package/src/core/snapshot.ts +144 -0
- package/src/core/storage.ts +250 -0
- package/src/example.ts +33 -0
- package/src/utils/logger.ts +69 -0
- package/src/watcher.ts +163 -0
- package/test-1-burst.sh +36 -0
- package/test-2-selective.sh +27 -0
- package/test-3-compression.sh +95 -0
- package/test-burst-1.ts +1 -0
- package/test-burst-2.ts +1 -0
- package/test-burst-3.ts +1 -0
- package/test-compression/file1.txt +2 -0
- package/test-compression/file2.txt +2 -0
- package/test-compression/file3.txt +1 -0
- package/test-watcher.sh +21 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Storage, SnapshotMetadata } from './storage.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Snapshot manager for creating and restoring snapshots
|
|
5
|
+
*/
|
|
6
|
+
export class SnapshotManager {
|
|
7
|
+
private projectRoot: string;
|
|
8
|
+
|
|
9
|
+
constructor(projectRoot: string) {
|
|
10
|
+
this.projectRoot = projectRoot;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a snapshot from changed files
|
|
15
|
+
*/
|
|
16
|
+
createSnapshot(
|
|
17
|
+
changedFiles: Set<string>,
|
|
18
|
+
label: 'AI_BURST' | 'AUTO' = 'AI_BURST'
|
|
19
|
+
): string {
|
|
20
|
+
// Generate snapshot ID (timestamp)
|
|
21
|
+
const timestamp = Date.now();
|
|
22
|
+
const snapshotId = timestamp.toString();
|
|
23
|
+
|
|
24
|
+
// Create snapshot directory
|
|
25
|
+
Storage.createSnapshotDir(snapshotId);
|
|
26
|
+
|
|
27
|
+
// Filter files: only snapshot files that still exist
|
|
28
|
+
// (skip deleted files to avoid ENOENT errors)
|
|
29
|
+
const fileArray = Array.from(changedFiles);
|
|
30
|
+
const existingFiles: string[] = [];
|
|
31
|
+
const skippedFiles: string[] = [];
|
|
32
|
+
|
|
33
|
+
for (const file of fileArray) {
|
|
34
|
+
if (Storage.fileExists(file)) {
|
|
35
|
+
try {
|
|
36
|
+
Storage.copyFileToSnapshot(file, snapshotId, this.projectRoot);
|
|
37
|
+
existingFiles.push(file);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(`Failed to copy file ${file}:`, error);
|
|
40
|
+
skippedFiles.push(file);
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
// File was deleted before snapshot - skip it
|
|
44
|
+
skippedFiles.push(file);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Create metadata with only successfully backed up files
|
|
49
|
+
const metadata: SnapshotMetadata = {
|
|
50
|
+
timestamp,
|
|
51
|
+
date: new Date(timestamp).toISOString(),
|
|
52
|
+
projectRoot: this.projectRoot,
|
|
53
|
+
changedFiles: existingFiles, // Only files that were actually saved
|
|
54
|
+
fileCount: existingFiles.length,
|
|
55
|
+
label,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Save metadata
|
|
59
|
+
Storage.saveMetadata(snapshotId, metadata);
|
|
60
|
+
|
|
61
|
+
// Log summary if files were skipped
|
|
62
|
+
if (skippedFiles.length > 0) {
|
|
63
|
+
console.log(`⚠️ Skipped ${skippedFiles.length} deleted file(s)`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return snapshotId;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Restore files from a snapshot
|
|
71
|
+
*/
|
|
72
|
+
restoreSnapshot(snapshotId: string): number {
|
|
73
|
+
// Get metadata
|
|
74
|
+
const metadata = Storage.getSnapshotMetadata(snapshotId);
|
|
75
|
+
|
|
76
|
+
if (!metadata) {
|
|
77
|
+
throw new Error(`Snapshot ${snapshotId} not found`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check if project root matches
|
|
81
|
+
if (metadata.projectRoot !== this.projectRoot) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Snapshot was created in different project: ${metadata.projectRoot}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Restore all files
|
|
88
|
+
let restoredCount = 0;
|
|
89
|
+
for (const file of metadata.changedFiles) {
|
|
90
|
+
try {
|
|
91
|
+
Storage.restoreFileFromSnapshot(file, snapshotId, this.projectRoot);
|
|
92
|
+
restoredCount++;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error(`Failed to restore file ${file}:`, error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return restoredCount;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all available snapshots
|
|
103
|
+
*/
|
|
104
|
+
listSnapshots(): Array<{ id: string; metadata: SnapshotMetadata }> {
|
|
105
|
+
const snapshotIds = Storage.getSnapshotIds();
|
|
106
|
+
const snapshots = [];
|
|
107
|
+
|
|
108
|
+
for (const id of snapshotIds) {
|
|
109
|
+
const metadata = Storage.getSnapshotMetadata(id);
|
|
110
|
+
if (metadata) {
|
|
111
|
+
snapshots.push({ id, metadata });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return snapshots;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get snapshot details
|
|
120
|
+
*/
|
|
121
|
+
getSnapshot(snapshotId: string): { id: string; metadata: SnapshotMetadata } | null {
|
|
122
|
+
const metadata = Storage.getSnapshotMetadata(snapshotId);
|
|
123
|
+
|
|
124
|
+
if (!metadata) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { id: snapshotId, metadata };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Delete a snapshot
|
|
133
|
+
*/
|
|
134
|
+
deleteSnapshot(snapshotId: string): void {
|
|
135
|
+
Storage.deleteSnapshot(snapshotId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get snapshot count
|
|
140
|
+
*/
|
|
141
|
+
getSnapshotCount(): number {
|
|
142
|
+
return Storage.getSnapshotIds().length;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import zlib from 'zlib';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Storage paths configuration
|
|
8
|
+
*/
|
|
9
|
+
export const STORAGE_PATHS = {
|
|
10
|
+
root: path.join(homedir(), '.undoai'),
|
|
11
|
+
snapshots: path.join(homedir(), '.undoai', 'snapshots'),
|
|
12
|
+
daemonPid: path.join(homedir(), '.undoai', 'daemon.pid'),
|
|
13
|
+
config: path.join(homedir(), '.undoai', 'config.json'),
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Metadata for a snapshot
|
|
18
|
+
*/
|
|
19
|
+
export interface SnapshotMetadata {
|
|
20
|
+
timestamp: number;
|
|
21
|
+
date: string;
|
|
22
|
+
projectRoot: string;
|
|
23
|
+
changedFiles: string[];
|
|
24
|
+
fileCount: number;
|
|
25
|
+
label: 'AI_BURST' | 'AUTO';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Storage manager for undoai snapshots
|
|
30
|
+
*/
|
|
31
|
+
export class Storage {
|
|
32
|
+
/**
|
|
33
|
+
* Initialize storage directory structure
|
|
34
|
+
*/
|
|
35
|
+
static init(): void {
|
|
36
|
+
// Create root directory
|
|
37
|
+
if (!fs.existsSync(STORAGE_PATHS.root)) {
|
|
38
|
+
fs.mkdirSync(STORAGE_PATHS.root, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Create snapshots directory
|
|
42
|
+
if (!fs.existsSync(STORAGE_PATHS.snapshots)) {
|
|
43
|
+
fs.mkdirSync(STORAGE_PATHS.snapshots, { recursive: true });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Check if storage is initialized
|
|
49
|
+
*/
|
|
50
|
+
static isInitialized(): boolean {
|
|
51
|
+
return fs.existsSync(STORAGE_PATHS.root) && fs.existsSync(STORAGE_PATHS.snapshots);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a file exists
|
|
56
|
+
*/
|
|
57
|
+
static fileExists(filePath: string): boolean {
|
|
58
|
+
try {
|
|
59
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get all snapshot IDs (sorted by timestamp, newest first)
|
|
68
|
+
*/
|
|
69
|
+
static getSnapshotIds(): string[] {
|
|
70
|
+
if (!fs.existsSync(STORAGE_PATHS.snapshots)) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entries = fs.readdirSync(STORAGE_PATHS.snapshots, { withFileTypes: true });
|
|
75
|
+
const snapshotIds = entries
|
|
76
|
+
.filter((entry) => entry.isDirectory())
|
|
77
|
+
.map((entry) => entry.name)
|
|
78
|
+
.sort((a, b) => parseInt(b) - parseInt(a)); // Newest first
|
|
79
|
+
|
|
80
|
+
return snapshotIds;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get snapshot metadata
|
|
85
|
+
*/
|
|
86
|
+
static getSnapshotMetadata(snapshotId: string): SnapshotMetadata | null {
|
|
87
|
+
const metadataPath = path.join(STORAGE_PATHS.snapshots, snapshotId, 'metadata.json');
|
|
88
|
+
|
|
89
|
+
if (!fs.existsSync(metadataPath)) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const content = fs.readFileSync(metadataPath, 'utf-8');
|
|
95
|
+
return JSON.parse(content);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`Failed to read metadata for snapshot ${snapshotId}:`, error);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get snapshot directory path
|
|
104
|
+
*/
|
|
105
|
+
static getSnapshotDir(snapshotId: string): string {
|
|
106
|
+
return path.join(STORAGE_PATHS.snapshots, snapshotId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get snapshot files directory path
|
|
111
|
+
*/
|
|
112
|
+
static getSnapshotFilesDir(snapshotId: string): string {
|
|
113
|
+
return path.join(STORAGE_PATHS.snapshots, snapshotId, 'files');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a new snapshot directory
|
|
118
|
+
*/
|
|
119
|
+
static createSnapshotDir(snapshotId: string): string {
|
|
120
|
+
const snapshotDir = this.getSnapshotDir(snapshotId);
|
|
121
|
+
const filesDir = this.getSnapshotFilesDir(snapshotId);
|
|
122
|
+
|
|
123
|
+
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
124
|
+
fs.mkdirSync(filesDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
return snapshotDir;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Save snapshot metadata
|
|
131
|
+
*/
|
|
132
|
+
static saveMetadata(snapshotId: string, metadata: SnapshotMetadata): void {
|
|
133
|
+
const metadataPath = path.join(STORAGE_PATHS.snapshots, snapshotId, 'metadata.json');
|
|
134
|
+
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Convert file path to safe filename (replace / with __)
|
|
139
|
+
* Example: /home/user/project/src/auth.ts -> src__auth.ts
|
|
140
|
+
*/
|
|
141
|
+
static pathToSafeFilename(filePath: string, projectRoot: string): string {
|
|
142
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
143
|
+
return relativePath.replace(/\//g, '__').replace(/\\/g, '__');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Convert safe filename back to relative path
|
|
148
|
+
* Example: src__auth.ts -> src/auth.ts
|
|
149
|
+
*/
|
|
150
|
+
static safeFilenameToPath(safeFilename: string): string {
|
|
151
|
+
return safeFilename.replace(/__/g, path.sep);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Copy file to snapshot with compression
|
|
156
|
+
*/
|
|
157
|
+
static copyFileToSnapshot(
|
|
158
|
+
sourceFile: string,
|
|
159
|
+
snapshotId: string,
|
|
160
|
+
projectRoot: string
|
|
161
|
+
): void {
|
|
162
|
+
const safeFilename = this.pathToSafeFilename(sourceFile, projectRoot);
|
|
163
|
+
const destPath = path.join(this.getSnapshotFilesDir(snapshotId), safeFilename + '.gz');
|
|
164
|
+
|
|
165
|
+
// Read, compress, and save file
|
|
166
|
+
const fileContent = fs.readFileSync(sourceFile);
|
|
167
|
+
const compressed = zlib.gzipSync(fileContent);
|
|
168
|
+
fs.writeFileSync(destPath, compressed);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Restore file from snapshot with decompression
|
|
173
|
+
*/
|
|
174
|
+
static restoreFileFromSnapshot(
|
|
175
|
+
originalPath: string,
|
|
176
|
+
snapshotId: string,
|
|
177
|
+
projectRoot: string
|
|
178
|
+
): void {
|
|
179
|
+
const safeFilename = this.pathToSafeFilename(originalPath, projectRoot);
|
|
180
|
+
const sourcePath = path.join(this.getSnapshotFilesDir(snapshotId), safeFilename + '.gz');
|
|
181
|
+
|
|
182
|
+
if (!fs.existsSync(sourcePath)) {
|
|
183
|
+
throw new Error(`Snapshot file not found: ${safeFilename}.gz`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Ensure target directory exists
|
|
187
|
+
const targetDir = path.dirname(originalPath);
|
|
188
|
+
if (!fs.existsSync(targetDir)) {
|
|
189
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Decompress and restore file
|
|
193
|
+
const compressed = fs.readFileSync(sourcePath);
|
|
194
|
+
const decompressed = zlib.gunzipSync(compressed);
|
|
195
|
+
fs.writeFileSync(originalPath, decompressed);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Delete a snapshot
|
|
200
|
+
*/
|
|
201
|
+
static deleteSnapshot(snapshotId: string): void {
|
|
202
|
+
const snapshotDir = this.getSnapshotDir(snapshotId);
|
|
203
|
+
|
|
204
|
+
if (fs.existsSync(snapshotDir)) {
|
|
205
|
+
fs.rmSync(snapshotDir, { recursive: true, force: true });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get total storage size in bytes
|
|
211
|
+
*/
|
|
212
|
+
static getStorageSize(): number {
|
|
213
|
+
if (!fs.existsSync(STORAGE_PATHS.snapshots)) {
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let totalSize = 0;
|
|
218
|
+
|
|
219
|
+
const calculateDirSize = (dirPath: string): void => {
|
|
220
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
221
|
+
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
224
|
+
|
|
225
|
+
if (entry.isDirectory()) {
|
|
226
|
+
calculateDirSize(fullPath);
|
|
227
|
+
} else {
|
|
228
|
+
const stats = fs.statSync(fullPath);
|
|
229
|
+
totalSize += stats.size;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
calculateDirSize(STORAGE_PATHS.snapshots);
|
|
235
|
+
return totalSize;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Format bytes to human readable string
|
|
240
|
+
*/
|
|
241
|
+
static formatBytes(bytes: number): string {
|
|
242
|
+
if (bytes === 0) return '0 Bytes';
|
|
243
|
+
|
|
244
|
+
const k = 1024;
|
|
245
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
246
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
247
|
+
|
|
248
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/example.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { FileWatcher } from './watcher.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example usage of the FileWatcher
|
|
5
|
+
*/
|
|
6
|
+
function main() {
|
|
7
|
+
// Initialize the watcher
|
|
8
|
+
const watcher = new FileWatcher({
|
|
9
|
+
watchPath: process.cwd(), // Watch current directory
|
|
10
|
+
onBurstChange: (changedFiles) => {
|
|
11
|
+
console.log(`\n🔥 Burst detected! ${changedFiles.size} files changed:`);
|
|
12
|
+
changedFiles.forEach((file) => {
|
|
13
|
+
console.log(` - ${file}`);
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
burstThreshold: 5, // Trigger when ≥5 files change
|
|
17
|
+
debounceDelay: 2000, // After 2000ms of no changes
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Start watching
|
|
21
|
+
console.log('👀 Watching for file changes...');
|
|
22
|
+
console.log('💡 Make changes to ≥5 files, then wait 2 seconds\n');
|
|
23
|
+
watcher.start();
|
|
24
|
+
|
|
25
|
+
// Handle graceful shutdown
|
|
26
|
+
process.on('SIGINT', async () => {
|
|
27
|
+
console.log('\n\n👋 Stopping watcher...');
|
|
28
|
+
await watcher.stop();
|
|
29
|
+
process.exit(0);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
main();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Logger utility with colored output
|
|
5
|
+
*/
|
|
6
|
+
export class Logger {
|
|
7
|
+
/**
|
|
8
|
+
* Success message (green with checkmark)
|
|
9
|
+
*/
|
|
10
|
+
static success(message: string): void {
|
|
11
|
+
console.log(chalk.green('✅ ' + message));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Error message (red with cross)
|
|
16
|
+
*/
|
|
17
|
+
static error(message: string): void {
|
|
18
|
+
console.log(chalk.red('❌ ' + message));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Warning message (yellow with warning icon)
|
|
23
|
+
*/
|
|
24
|
+
static warn(message: string): void {
|
|
25
|
+
console.log(chalk.yellow('⚠️ ' + message));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Info message (blue with info icon)
|
|
30
|
+
*/
|
|
31
|
+
static info(message: string): void {
|
|
32
|
+
console.log(chalk.blue('ℹ️ ' + message));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Snapshot message (camera icon)
|
|
37
|
+
*/
|
|
38
|
+
static snapshot(message: string): void {
|
|
39
|
+
console.log(chalk.cyan('📸 ' + message));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Watch message (eyes icon)
|
|
44
|
+
*/
|
|
45
|
+
static watch(message: string): void {
|
|
46
|
+
console.log(chalk.magenta('👀 ' + message));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Plain message (no icon or color)
|
|
51
|
+
*/
|
|
52
|
+
static plain(message: string): void {
|
|
53
|
+
console.log(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Dim/subtle message
|
|
58
|
+
*/
|
|
59
|
+
static dim(message: string): void {
|
|
60
|
+
console.log(chalk.dim(message));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Bold message
|
|
65
|
+
*/
|
|
66
|
+
static bold(message: string): void {
|
|
67
|
+
console.log(chalk.bold(message));
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/watcher.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration options for the file watcher
|
|
6
|
+
*/
|
|
7
|
+
export interface WatcherOptions {
|
|
8
|
+
/** Directory to watch for file changes */
|
|
9
|
+
watchPath: string;
|
|
10
|
+
/** Callback triggered when burst threshold is met */
|
|
11
|
+
onBurstChange: (changedFiles: Set<string>) => void;
|
|
12
|
+
/** Minimum number of files that must change to trigger burst (default: 5) */
|
|
13
|
+
burstThreshold?: number;
|
|
14
|
+
/** Debounce delay in milliseconds (default: 2000ms) */
|
|
15
|
+
debounceDelay?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* File watcher that monitors directory changes and triggers callbacks
|
|
20
|
+
* when a burst of changes occurs (≥5 files, no changes for ≥2000ms)
|
|
21
|
+
*/
|
|
22
|
+
export class FileWatcher {
|
|
23
|
+
private watcher: chokidar.FSWatcher | null = null;
|
|
24
|
+
private changedFiles: Set<string> = new Set();
|
|
25
|
+
private debounceTimer: NodeJS.Timeout | null = null;
|
|
26
|
+
|
|
27
|
+
private readonly watchPath: string;
|
|
28
|
+
private readonly onBurstChange: (changedFiles: Set<string>) => void;
|
|
29
|
+
private readonly burstThreshold: number;
|
|
30
|
+
private readonly debounceDelay: number;
|
|
31
|
+
|
|
32
|
+
constructor(options: WatcherOptions) {
|
|
33
|
+
this.watchPath = options.watchPath;
|
|
34
|
+
this.onBurstChange = options.onBurstChange;
|
|
35
|
+
this.burstThreshold = options.burstThreshold ?? 5;
|
|
36
|
+
this.debounceDelay = options.debounceDelay ?? 2000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start watching the directory for file changes
|
|
41
|
+
*/
|
|
42
|
+
public start(): void {
|
|
43
|
+
this.watcher = chokidar.watch(this.watchPath, {
|
|
44
|
+
// Watch configuration
|
|
45
|
+
persistent: true,
|
|
46
|
+
ignoreInitial: true, // Don't trigger events for existing files on startup
|
|
47
|
+
|
|
48
|
+
// Ignored paths - use glob patterns
|
|
49
|
+
ignored: [
|
|
50
|
+
'**/node_modules/**',
|
|
51
|
+
'**/.git/**',
|
|
52
|
+
'**/dist/**',
|
|
53
|
+
'**/build/**',
|
|
54
|
+
'**/_tmp_*', // Temp files
|
|
55
|
+
'**/*.tmp', // Temp extensions
|
|
56
|
+
'**/pnpm-lock.yaml.*', // pnpm temp lock files
|
|
57
|
+
'**/package.json.*', // npm/pnpm temp package files
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Handle file addition
|
|
62
|
+
this.watcher.on('add', (filePath: string) => {
|
|
63
|
+
this.handleFileChange(filePath, 'add');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Handle file modification
|
|
67
|
+
this.watcher.on('change', (filePath: string) => {
|
|
68
|
+
this.handleFileChange(filePath, 'change');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Handle file deletion
|
|
72
|
+
this.watcher.on('unlink', (filePath: string) => {
|
|
73
|
+
this.handleFileChange(filePath, 'unlink');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Handle file rename - chokidar treats this as unlink (old) + add (new)
|
|
77
|
+
// We handle both events separately, so no special handling needed
|
|
78
|
+
// The rename effectively becomes two separate events in our changedFiles Set
|
|
79
|
+
|
|
80
|
+
// Error handling
|
|
81
|
+
this.watcher.on('error', (error: Error) => {
|
|
82
|
+
console.error('Watcher error:', error);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Handle a file change event
|
|
88
|
+
* Debounce Logic:
|
|
89
|
+
* 1. Add the file path to the Set (automatically prevents duplicates)
|
|
90
|
+
* 2. Clear any existing debounce timer
|
|
91
|
+
* 3. Start a new timer
|
|
92
|
+
* 4. If timer expires (no new changes for debounceDelay ms):
|
|
93
|
+
* - Check if we have ≥ burstThreshold unique files
|
|
94
|
+
* - If yes, trigger the callback and reset the buffer
|
|
95
|
+
*/
|
|
96
|
+
private handleFileChange(filePath: string, eventType: string): void {
|
|
97
|
+
// Debug: log individual file changes
|
|
98
|
+
console.log(` 📝 [${eventType}] ${filePath}`);
|
|
99
|
+
|
|
100
|
+
// Add file to the set of changed files (Set automatically prevents duplicates)
|
|
101
|
+
this.changedFiles.add(filePath);
|
|
102
|
+
|
|
103
|
+
// Clear the existing debounce timer if any
|
|
104
|
+
if (this.debounceTimer) {
|
|
105
|
+
clearTimeout(this.debounceTimer);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Start a new debounce timer
|
|
109
|
+
this.debounceTimer = setTimeout(() => {
|
|
110
|
+
// Timer expired - no new changes for debounceDelay ms
|
|
111
|
+
|
|
112
|
+
// ALWAYS trigger callback if there are ANY changes
|
|
113
|
+
// Let the callback (smart detection in watch.ts) decide whether to snapshot
|
|
114
|
+
if (this.changedFiles.size > 0) {
|
|
115
|
+
// Create a copy of the changed files set for the callback
|
|
116
|
+
const filesSnapshot = new Set(this.changedFiles);
|
|
117
|
+
|
|
118
|
+
// Clear the buffer before triggering callback
|
|
119
|
+
this.changedFiles.clear();
|
|
120
|
+
|
|
121
|
+
// Trigger callback - smart detection will decide if snapshot needed
|
|
122
|
+
this.onBurstChange(filesSnapshot);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Clear the timer reference
|
|
126
|
+
this.debounceTimer = null;
|
|
127
|
+
}, this.debounceDelay);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stop watching and clean up resources
|
|
132
|
+
*/
|
|
133
|
+
public async stop(): Promise<void> {
|
|
134
|
+
// Clear any pending debounce timer
|
|
135
|
+
if (this.debounceTimer) {
|
|
136
|
+
clearTimeout(this.debounceTimer);
|
|
137
|
+
this.debounceTimer = null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Close the watcher
|
|
141
|
+
if (this.watcher) {
|
|
142
|
+
await this.watcher.close();
|
|
143
|
+
this.watcher = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Clear the changed files buffer
|
|
147
|
+
this.changedFiles.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get the current count of changed files in the buffer
|
|
152
|
+
*/
|
|
153
|
+
public getChangedFilesCount(): number {
|
|
154
|
+
return this.changedFiles.size;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Manually clear the changed files buffer
|
|
159
|
+
*/
|
|
160
|
+
public clearBuffer(): void {
|
|
161
|
+
this.changedFiles.clear();
|
|
162
|
+
}
|
|
163
|
+
}
|
package/test-1-burst.sh
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Test 1: Burst Detection (3+ files)
|
|
3
|
+
|
|
4
|
+
echo "🧪 Test 1: Burst Detection"
|
|
5
|
+
echo "=========================="
|
|
6
|
+
echo ""
|
|
7
|
+
|
|
8
|
+
# Cleanup
|
|
9
|
+
rm -f test-burst-*.ts 2>/dev/null
|
|
10
|
+
|
|
11
|
+
echo "Creating 3 test files..."
|
|
12
|
+
sleep 1
|
|
13
|
+
|
|
14
|
+
# Create 3 files rapidly
|
|
15
|
+
echo "export const test1 = 'hello';" > test-burst-1.ts
|
|
16
|
+
echo "export const test2 = 'world';" > test-burst-2.ts
|
|
17
|
+
echo "export const test3 = 'foo';" > test-burst-3.ts
|
|
18
|
+
|
|
19
|
+
echo "✅ Created 3 files"
|
|
20
|
+
echo ""
|
|
21
|
+
echo "Wait 3 seconds for debounce..."
|
|
22
|
+
sleep 3
|
|
23
|
+
|
|
24
|
+
echo ""
|
|
25
|
+
echo "📊 Check watcher output above for:"
|
|
26
|
+
echo " - Should see: 📸 Snapshot saved (3 files changed)"
|
|
27
|
+
echo " - Should see: Reason: Burst detected (3 files)"
|
|
28
|
+
echo ""
|
|
29
|
+
|
|
30
|
+
# Check snapshot created
|
|
31
|
+
SNAPSHOT_COUNT=$(ls -1 ~/.undoai/snapshots/ 2>/dev/null | wc -l)
|
|
32
|
+
echo "📁 Total snapshots: $SNAPSHOT_COUNT"
|
|
33
|
+
echo ""
|
|
34
|
+
|
|
35
|
+
echo "✅ Test 1 completed!"
|
|
36
|
+
echo ""
|