undoai 0.1.0-beta.1 → 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.
@@ -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
+ }
@@ -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 ""