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,222 @@
1
+ import inquirer from 'inquirer';
2
+ import { SnapshotManager } from '../../core/snapshot.js';
3
+ import { Storage } from '../../core/storage.js';
4
+ import { Logger } from '../../utils/logger.js';
5
+ import path from 'path';
6
+ import { minimatch } from 'minimatch';
7
+
8
+ /**
9
+ * Format relative time
10
+ */
11
+ function formatRelativeTime(timestamp: number): string {
12
+ const now = Date.now();
13
+ const diff = now - timestamp;
14
+
15
+ const seconds = Math.floor(diff / 1000);
16
+ const minutes = Math.floor(seconds / 60);
17
+ const hours = Math.floor(minutes / 60);
18
+ const days = Math.floor(hours / 24);
19
+
20
+ if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
21
+ if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
22
+ if (minutes > 0) return `${minutes} min${minutes > 1 ? 's' : ''} ago`;
23
+ return 'just now';
24
+ }
25
+
26
+ interface RestoreOptions {
27
+ interactive?: boolean;
28
+ files?: string;
29
+ pattern?: string;
30
+ }
31
+
32
+ /**
33
+ * Restore command - restore files from a snapshot
34
+ */
35
+ export async function restoreCommand(options: RestoreOptions = {}): Promise<void> {
36
+ // Initialize storage if needed
37
+ if (!Storage.isInitialized()) {
38
+ Logger.error('No snapshots found. Start watching with "undoai watch" first.');
39
+ process.exit(1);
40
+ }
41
+
42
+ // Get current directory
43
+ const projectRoot = process.cwd();
44
+ const snapshotManager = new SnapshotManager(projectRoot);
45
+
46
+ // List available snapshots
47
+ const snapshots = snapshotManager.listSnapshots();
48
+
49
+ // Filter out empty snapshots (snapshots with 0 files)
50
+ const validSnapshots = snapshots.filter(s => s.metadata.fileCount > 0);
51
+
52
+ if (validSnapshots.length === 0) {
53
+ Logger.error('No snapshots available');
54
+ Logger.info('Start watching with "undoai watch" to create snapshots');
55
+ process.exit(1);
56
+ }
57
+
58
+ console.log('');
59
+ Logger.info('Available snapshots:');
60
+ console.log('');
61
+
62
+ // Create choices for inquirer
63
+ const choices = validSnapshots.map((snapshot: { id: string; metadata: any }, index: number) => {
64
+ const { id, metadata } = snapshot;
65
+ const relativeTime = formatRelativeTime(metadata.timestamp);
66
+ const label = metadata.label === 'AI_BURST' ? 'šŸ¤– AI' : 'šŸ“ Auto';
67
+
68
+ return {
69
+ name: `${index + 1}. [${relativeTime}] ${metadata.fileCount} file${metadata.fileCount > 1 ? 's' : ''} ${label}`,
70
+ value: id,
71
+ short: `Snapshot ${index + 1}`,
72
+ };
73
+ });
74
+
75
+ // Add cancel option
76
+ choices.push({
77
+ name: 'āŒ Cancel',
78
+ value: 'CANCEL',
79
+ short: 'Cancel',
80
+ });
81
+
82
+ // Prompt user to select snapshot
83
+ const { snapshotId } = await inquirer.prompt([
84
+ {
85
+ type: 'list',
86
+ name: 'snapshotId',
87
+ message: 'Which snapshot do you want to restore?',
88
+ choices,
89
+ pageSize: 10,
90
+ },
91
+ ]);
92
+
93
+ if (snapshotId === 'CANCEL') {
94
+ Logger.info('Restore cancelled');
95
+ process.exit(0);
96
+ }
97
+
98
+ // Get snapshot details
99
+ const snapshot = snapshotManager.getSnapshot(snapshotId);
100
+ if (!snapshot) {
101
+ Logger.error('Snapshot not found');
102
+ process.exit(1);
103
+ }
104
+
105
+ // Determine which files to restore
106
+ let filesToRestore = snapshot.metadata.changedFiles;
107
+
108
+ // FEATURE: Pattern-based filtering
109
+ if (options.files) {
110
+ const fileList = options.files.split(',').map(f => f.trim());
111
+ filesToRestore = filesToRestore.filter((file: string) =>
112
+ fileList.some(pattern => minimatch(file, pattern))
113
+ );
114
+
115
+ if (filesToRestore.length === 0) {
116
+ Logger.error('No files match the specified pattern');
117
+ process.exit(1);
118
+ }
119
+
120
+ console.log('');
121
+ Logger.info(`Matched ${filesToRestore.length} files from pattern`);
122
+ }
123
+
124
+ // FEATURE: Interactive file selection
125
+ if (options.interactive) {
126
+ console.log('');
127
+ Logger.info('Select files to restore:');
128
+ console.log('');
129
+
130
+ const fileChoices = filesToRestore.map((file: string) => {
131
+ const relativePath = path.relative(projectRoot, file);
132
+ return {
133
+ name: relativePath,
134
+ value: file,
135
+ checked: false, // Default unchecked
136
+ };
137
+ });
138
+
139
+ const { selectedFiles } = await inquirer.prompt([
140
+ {
141
+ type: 'checkbox',
142
+ name: 'selectedFiles',
143
+ message: 'Select files (Space to select, Enter to confirm):',
144
+ choices: fileChoices,
145
+ pageSize: 15,
146
+ validate: (answer) => {
147
+ if (answer.length === 0) {
148
+ return 'You must select at least one file';
149
+ }
150
+ return true;
151
+ },
152
+ },
153
+ ]);
154
+
155
+ filesToRestore = selectedFiles;
156
+
157
+ if (filesToRestore.length === 0) {
158
+ Logger.info('No files selected');
159
+ process.exit(0);
160
+ }
161
+ }
162
+
163
+ // Show preview of what will be restored
164
+ console.log('');
165
+ Logger.info(`šŸ“‹ Preview: ${filesToRestore.length} file${filesToRestore.length > 1 ? 's' : ''} will be restored`);
166
+ console.log('');
167
+
168
+ filesToRestore.slice(0, 10).forEach((file: string) => {
169
+ const relativePath = path.relative(projectRoot, file);
170
+ console.log(` āœļø ${relativePath}`);
171
+ });
172
+
173
+ if (filesToRestore.length > 10) {
174
+ console.log(` ... and ${filesToRestore.length - 10} more`);
175
+ }
176
+
177
+ console.log('');
178
+ Logger.warn('āš ļø Current changes to these files will be overwritten');
179
+
180
+ // Confirm
181
+ const { confirm } = await inquirer.prompt([
182
+ {
183
+ type: 'confirm',
184
+ name: 'confirm',
185
+ message: 'Continue with restore?',
186
+ default: true,
187
+ },
188
+ ]);
189
+
190
+ if (!confirm) {
191
+ Logger.info('Restore cancelled');
192
+ process.exit(0);
193
+ }
194
+
195
+ // Restore snapshot (selective or full)
196
+ try {
197
+ let restoredCount = 0;
198
+
199
+ if (options.interactive || options.files) {
200
+ // Selective restore
201
+ for (const file of filesToRestore) {
202
+ try {
203
+ Storage.restoreFileFromSnapshot(file, snapshotId, projectRoot);
204
+ restoredCount++;
205
+ } catch (error) {
206
+ Logger.warn(`Failed to restore ${file}: ${error}`);
207
+ }
208
+ }
209
+ } else {
210
+ // Full restore
211
+ restoredCount = snapshotManager.restoreSnapshot(snapshotId);
212
+ }
213
+
214
+ console.log('');
215
+ Logger.success(`āœ… Restored ${restoredCount} file${restoredCount > 1 ? 's' : ''}`);
216
+ Logger.dim(`From: ${formatRelativeTime(snapshot.metadata.timestamp)}`);
217
+ } catch (error) {
218
+ console.log('');
219
+ Logger.error(`Failed to restore: ${error}`);
220
+ process.exit(1);
221
+ }
222
+ }
@@ -0,0 +1,51 @@
1
+ import { DaemonManager } from '../../core/daemon.js';
2
+ import { Storage, STORAGE_PATHS } from '../../core/storage.js';
3
+ import { Logger } from '../../utils/logger.js';
4
+ import chalk from 'chalk';
5
+
6
+ /**
7
+ * Status command - show daemon status and info
8
+ */
9
+ export async function statusCommand(): Promise<void> {
10
+ const isRunning = DaemonManager.isRunning();
11
+ const isInitialized = Storage.isInitialized();
12
+
13
+ console.log('');
14
+ Logger.bold('undoai Status');
15
+ console.log('');
16
+
17
+ // Daemon status
18
+ if (isRunning) {
19
+ const pid = DaemonManager.getPid();
20
+ console.log(chalk.green('🟢 Running') + chalk.dim(` (PID: ${pid})`));
21
+ } else {
22
+ console.log(chalk.red('šŸ”“ Not running'));
23
+ }
24
+
25
+ console.log('');
26
+
27
+ // Storage info
28
+ if (isInitialized) {
29
+ const snapshotIds = Storage.getSnapshotIds();
30
+ const snapshotCount = snapshotIds.length;
31
+ const storageSize = Storage.getStorageSize();
32
+
33
+ Logger.dim('šŸ’¾ Storage:');
34
+ console.log(chalk.dim(` Location: ${STORAGE_PATHS.root}`));
35
+ console.log(chalk.dim(` Snapshots: ${snapshotCount}`));
36
+ console.log(chalk.dim(` Size: ${Storage.formatBytes(storageSize)}`));
37
+ } else {
38
+ Logger.dim('šŸ’¾ Storage: Not initialized');
39
+ Logger.dim(' Run "undoai watch" to initialize');
40
+ }
41
+
42
+ console.log('');
43
+
44
+ // Show commands
45
+ Logger.dim('šŸ“ Commands:');
46
+ console.log(chalk.dim(' undoai watch - Start watching'));
47
+ console.log(chalk.dim(' undoai restore - Restore snapshot'));
48
+ console.log(chalk.dim(' undoai stop - Stop watching'));
49
+ console.log(chalk.dim(' undoai status - Show this status'));
50
+ console.log('');
51
+ }
@@ -0,0 +1,22 @@
1
+ import { DaemonManager } from '../../core/daemon.js';
2
+ import { Logger } from '../../utils/logger.js';
3
+
4
+ /**
5
+ * Stop command - stop watching daemon
6
+ */
7
+ export async function stopCommand(): Promise<void> {
8
+ if (!DaemonManager.isRunning()) {
9
+ Logger.error('undoai is not running');
10
+ Logger.info('Use "undoai watch" to start watching');
11
+ process.exit(1);
12
+ }
13
+
14
+ const stopped = DaemonManager.stop();
15
+
16
+ if (stopped) {
17
+ Logger.success('undoai stopped');
18
+ } else {
19
+ Logger.error('Failed to stop undoai');
20
+ process.exit(1);
21
+ }
22
+ }
@@ -0,0 +1,162 @@
1
+ import { FileWatcher } from '../../watcher.js';
2
+ import { SnapshotManager } from '../../core/snapshot.js';
3
+ import { Storage, STORAGE_PATHS } from '../../core/storage.js';
4
+ import { DaemonManager } from '../../core/daemon.js';
5
+ import { Logger } from '../../utils/logger.js';
6
+ import path from 'path';
7
+ import { minimatch } from 'minimatch';
8
+
9
+ /**
10
+ * Important file patterns that should trigger snapshot even if only 1 file changed
11
+ */
12
+ const IMPORTANT_FILE_PATTERNS = [
13
+ '.env',
14
+ '.env.*',
15
+ 'package.json',
16
+ 'package-lock.json',
17
+ 'pnpm-lock.yaml',
18
+ 'yarn.lock',
19
+ 'tsconfig.json',
20
+ 'jsconfig.json',
21
+ '**/*.prisma',
22
+ '**/schema.prisma',
23
+ '**/migrations/**',
24
+ 'Dockerfile',
25
+ 'docker-compose.yml',
26
+ 'docker-compose.*.yml',
27
+ '.github/workflows/**',
28
+ '.gitlab-ci.yml',
29
+ 'Jenkinsfile',
30
+ ];
31
+
32
+ /**
33
+ * Check if file is important (should trigger snapshot even if single file)
34
+ */
35
+ function isImportantFile(filePath: string, projectRoot: string): boolean {
36
+ const relativePath = path.relative(projectRoot, filePath);
37
+ return IMPORTANT_FILE_PATTERNS.some(pattern => minimatch(relativePath, pattern));
38
+ }
39
+
40
+ /**
41
+ * Smart detection: should we create snapshot?
42
+ */
43
+ function shouldCreateSnapshot(
44
+ changedFiles: Set<string>,
45
+ projectRoot: string,
46
+ timeDiff?: number
47
+ ): { should: boolean; reason: string } {
48
+ const fileCount = changedFiles.size;
49
+
50
+ // Priority 1: Burst detection (≄3 files)
51
+ if (fileCount >= 3) {
52
+ return { should: true, reason: `Burst detected (${fileCount} files)` };
53
+ }
54
+
55
+ // Priority 2: Important file changed (even if 1 file)
56
+ const hasImportant = Array.from(changedFiles).some(file =>
57
+ isImportantFile(file, projectRoot)
58
+ );
59
+ if (hasImportant) {
60
+ return { should: true, reason: 'Important file changed' };
61
+ }
62
+
63
+ // Priority 3: Velocity-based AI detection
64
+ if (timeDiff && fileCount >= 2 && timeDiff < 1000) {
65
+ // 2+ files in < 1 second = likely AI
66
+ return { should: true, reason: 'High velocity change (likely AI)' };
67
+ }
68
+
69
+ return { should: false, reason: 'Below threshold' };
70
+ }
71
+
72
+ /**
73
+ * Watch command - start watching for file changes
74
+ */
75
+ export async function watchCommand(): Promise<void> {
76
+ // Check if already running
77
+ if (DaemonManager.isRunning()) {
78
+ Logger.error('undoai is already watching');
79
+ Logger.info('Use "undoai stop" to stop watching');
80
+ process.exit(1);
81
+ }
82
+
83
+ // Initialize storage
84
+ Storage.init();
85
+
86
+ // Get current directory as project root
87
+ const projectRoot = process.cwd();
88
+
89
+ // Create snapshot manager
90
+ const snapshotManager = new SnapshotManager(projectRoot);
91
+
92
+ // Create initial snapshot if needed
93
+ const existingSnapshots = snapshotManager.listSnapshots();
94
+ if (existingSnapshots.length === 0) {
95
+ console.log('');
96
+ Logger.info('šŸ“ø Creating initial baseline snapshot...');
97
+ Logger.dim('This captures your current working state');
98
+
99
+ // We'll implement full initial snapshot in next iteration
100
+ // For now, just log that baseline will be created on first change
101
+ Logger.info('šŸ’” Baseline will be created on first file change');
102
+ console.log('');
103
+ }
104
+
105
+ let lastChangeTime = Date.now();
106
+
107
+ // Create file watcher with smart detection
108
+ const watcher = new FileWatcher({
109
+ watchPath: projectRoot,
110
+ onBurstChange: (changedFiles: Set<string>) => {
111
+ const currentTime = Date.now();
112
+ const timeDiff = currentTime - lastChangeTime;
113
+ lastChangeTime = currentTime;
114
+
115
+ // Smart detection
116
+ const detection = shouldCreateSnapshot(changedFiles, projectRoot, timeDiff);
117
+
118
+ if (detection.should) {
119
+ // Create snapshot
120
+ try {
121
+ const snapshotId = snapshotManager.createSnapshot(changedFiles, 'AI_BURST');
122
+ Logger.snapshot(`Snapshot saved (${changedFiles.size} files changed)`);
123
+ Logger.dim(`Reason: ${detection.reason}`);
124
+ Logger.dim(`Snapshot ID: ${snapshotId}`);
125
+ } catch (error) {
126
+ Logger.error(`Failed to create snapshot: ${error}`);
127
+ }
128
+ } else {
129
+ // Just log, no snapshot
130
+ Logger.dim(`${changedFiles.size} file(s) changed (${detection.reason})`);
131
+ }
132
+ },
133
+ burstThreshold: 3, // Lowered from 5 to 3
134
+ debounceDelay: 2000,
135
+ });
136
+
137
+ // Start watching
138
+ watcher.start();
139
+
140
+ // Mark daemon as running
141
+ DaemonManager.markAsRunning(process.pid);
142
+
143
+ // Display startup message
144
+ console.log('');
145
+ Logger.success('undoai is now watching');
146
+ Logger.dim(`šŸ“ Project: ${projectRoot}`);
147
+ Logger.dim(`šŸ’¾ Storage: ${STORAGE_PATHS.root}`);
148
+ Logger.dim(`šŸ”’ 100% local - your code never leaves this machine`);
149
+ console.log('');
150
+ Logger.info('šŸŽÆ Smart detection enabled:');
151
+ Logger.dim(' • ≄3 files changed = snapshot');
152
+ Logger.dim(' • Important files (.env, package.json, etc) = snapshot');
153
+ Logger.dim(' • High velocity changes = snapshot');
154
+ console.log('');
155
+ Logger.info('Watching for file changes... (Press Ctrl+C to stop)');
156
+ console.log('');
157
+
158
+ // Setup graceful shutdown
159
+ DaemonManager.setupShutdownHandlers(async () => {
160
+ await watcher.stop();
161
+ });
162
+ }
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { watchCommand } from './commands/watch.js';
5
+ import { restoreCommand } from './commands/restore.js';
6
+ import { stopCommand } from './commands/stop.js';
7
+ import { statusCommand } from './commands/status.js';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('undoai')
13
+ .description('Free, local undo button for AI coding')
14
+ .version('1.0.0');
15
+
16
+ // Watch command
17
+ program
18
+ .command('watch')
19
+ .description('Start watching for file changes')
20
+ .action(async () => {
21
+ await watchCommand();
22
+ });
23
+
24
+ // Restore command
25
+ program
26
+ .command('restore')
27
+ .description('Restore files from a snapshot')
28
+ .option('-i, --interactive', 'Select specific files to restore')
29
+ .option('-f, --files <patterns>', 'Restore specific files (comma-separated patterns)')
30
+ .option('-p, --pattern <glob>', 'Restore files matching glob pattern')
31
+ .action(async (options) => {
32
+ await restoreCommand(options);
33
+ });
34
+
35
+ // Stop command
36
+ program
37
+ .command('stop')
38
+ .description('Stop watching daemon')
39
+ .action(async () => {
40
+ await stopCommand();
41
+ });
42
+
43
+ // Status command
44
+ program
45
+ .command('status')
46
+ .description('Show undoai status')
47
+ .action(async () => {
48
+ await statusCommand();
49
+ });
50
+
51
+ // Parse arguments
52
+ program.parse();
@@ -0,0 +1,104 @@
1
+ import fs from 'fs';
2
+ import { spawn, ChildProcess } from 'child_process';
3
+ import { STORAGE_PATHS } from './storage.js';
4
+
5
+ /**
6
+ * Daemon process manager
7
+ */
8
+ export class DaemonManager {
9
+ /**
10
+ * Check if daemon is running
11
+ */
12
+ static isRunning(): boolean {
13
+ if (!fs.existsSync(STORAGE_PATHS.daemonPid)) {
14
+ return false;
15
+ }
16
+
17
+ try {
18
+ const pid = parseInt(fs.readFileSync(STORAGE_PATHS.daemonPid, 'utf-8'));
19
+
20
+ // Check if process exists
21
+ process.kill(pid, 0);
22
+ return true;
23
+ } catch (error) {
24
+ // Process doesn't exist, clean up stale PID file
25
+ this.cleanupPidFile();
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Get daemon PID
32
+ */
33
+ static getPid(): number | null {
34
+ if (!fs.existsSync(STORAGE_PATHS.daemonPid)) {
35
+ return null;
36
+ }
37
+
38
+ try {
39
+ return parseInt(fs.readFileSync(STORAGE_PATHS.daemonPid, 'utf-8'));
40
+ } catch (error) {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Save daemon PID
47
+ */
48
+ static savePid(pid: number): void {
49
+ fs.writeFileSync(STORAGE_PATHS.daemonPid, pid.toString(), 'utf-8');
50
+ }
51
+
52
+ /**
53
+ * Clean up PID file
54
+ */
55
+ static cleanupPidFile(): void {
56
+ if (fs.existsSync(STORAGE_PATHS.daemonPid)) {
57
+ fs.unlinkSync(STORAGE_PATHS.daemonPid);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Stop daemon process
63
+ */
64
+ static stop(): boolean {
65
+ const pid = this.getPid();
66
+
67
+ if (!pid) {
68
+ return false;
69
+ }
70
+
71
+ try {
72
+ process.kill(pid, 'SIGTERM');
73
+ this.cleanupPidFile();
74
+ return true;
75
+ } catch (error) {
76
+ // Process doesn't exist
77
+ this.cleanupPidFile();
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Start daemon in background (for watch command)
84
+ * Note: This is called from the watch command itself
85
+ */
86
+ static markAsRunning(pid: number): void {
87
+ this.savePid(pid);
88
+ }
89
+
90
+ /**
91
+ * Setup graceful shutdown handlers
92
+ */
93
+ static setupShutdownHandlers(onShutdown: () => Promise<void>): void {
94
+ const shutdown = async (signal: string) => {
95
+ console.log(`\n\nšŸ‘‹ Received ${signal}, stopping watcher...`);
96
+ await onShutdown();
97
+ this.cleanupPidFile();
98
+ process.exit(0);
99
+ };
100
+
101
+ process.on('SIGINT', () => shutdown('SIGINT'));
102
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
103
+ }
104
+ }