revert-ai 1.1.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/LICENSE +25 -0
- package/README.md +103 -0
- package/bin/revert-ai.js +589 -0
- package/index.js +4 -0
- package/package.json +74 -0
- package/src/core/AgentDetector.js +27 -0
- package/src/core/AgentParser.js +34 -0
- package/src/core/AiderSessionParser.js +143 -0
- package/src/core/ClaudeSessionParser.js +243 -0
- package/src/core/DependencyCascading.js +74 -0
- package/src/core/DependencyResolver.js +206 -0
- package/src/core/Operation.js +39 -0
- package/src/core/OperationPreview.js +176 -0
- package/src/core/RedoManager.js +270 -0
- package/src/core/SessionTracker.js +98 -0
- package/src/core/UndoManager.js +207 -0
- package/src/core/UndoTracker.js +72 -0
- package/src/hooks/claude-tracker.js +92 -0
- package/src/i18n/i18n.js +72 -0
- package/src/i18n/languages.js +471 -0
- package/src/utils/diffRenderer.js +112 -0
- package/src/utils/formatting.js +15 -0
- package/src/utils/gitHelper.js +66 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
export class Operation {
|
|
4
|
+
constructor(type, data) {
|
|
5
|
+
this.id = (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.floor(Math.random()*1e9)}`);
|
|
6
|
+
this.timestamp = new Date();
|
|
7
|
+
this.type = type;
|
|
8
|
+
this.data = data;
|
|
9
|
+
this.undone = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
static fromJSON(json) {
|
|
13
|
+
const op = new Operation(json.type, json.data);
|
|
14
|
+
op.id = json.id;
|
|
15
|
+
op.timestamp = new Date(json.timestamp);
|
|
16
|
+
op.undone = json.undone || false;
|
|
17
|
+
return op;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toJSON() {
|
|
21
|
+
return {
|
|
22
|
+
id: this.id,
|
|
23
|
+
timestamp: this.timestamp.toISOString(),
|
|
24
|
+
type: this.type,
|
|
25
|
+
data: this.data,
|
|
26
|
+
undone: this.undone
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const OperationType = {
|
|
32
|
+
FILE_CREATE: 'file_create',
|
|
33
|
+
FILE_EDIT: 'file_edit',
|
|
34
|
+
FILE_DELETE: 'file_delete',
|
|
35
|
+
FILE_RENAME: 'file_rename',
|
|
36
|
+
DIRECTORY_CREATE: 'directory_create',
|
|
37
|
+
DIRECTORY_DELETE: 'directory_delete',
|
|
38
|
+
BASH_COMMAND: 'bash_command'
|
|
39
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { OperationType } from './Operation.js';
|
|
4
|
+
import { i18n } from '../i18n/i18n.js';
|
|
5
|
+
import { renderSideBySide } from '../utils/diffRenderer.js';
|
|
6
|
+
|
|
7
|
+
export class OperationPreview {
|
|
8
|
+
static async generatePreview(operation) {
|
|
9
|
+
switch (operation.type) {
|
|
10
|
+
case OperationType.FILE_CREATE:
|
|
11
|
+
return await this.previewFileCreate(operation);
|
|
12
|
+
case OperationType.FILE_EDIT:
|
|
13
|
+
return await this.previewFileEdit(operation);
|
|
14
|
+
case OperationType.FILE_DELETE:
|
|
15
|
+
return await this.previewFileDelete(operation);
|
|
16
|
+
case OperationType.FILE_RENAME:
|
|
17
|
+
return await this.previewFileRename(operation);
|
|
18
|
+
case OperationType.DIRECTORY_CREATE:
|
|
19
|
+
return await this.previewDirectoryCreate(operation);
|
|
20
|
+
case OperationType.DIRECTORY_DELETE:
|
|
21
|
+
return await this.previewDirectoryDelete(operation);
|
|
22
|
+
case OperationType.BASH_COMMAND:
|
|
23
|
+
return await this.previewBashCommand(operation);
|
|
24
|
+
default:
|
|
25
|
+
return { preview: `Unknown operation: ${operation.type}`, hasContent: false };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static async previewFileCreate(operation) {
|
|
30
|
+
const { filePath } = operation.data;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
34
|
+
if (!exists) {
|
|
35
|
+
return {
|
|
36
|
+
preview: `${chalk.red('File does not exist:')} ${filePath}`,
|
|
37
|
+
hasContent: false
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
42
|
+
const lines = content.split('\n');
|
|
43
|
+
const preview = lines.slice(0, 5).join('\n');
|
|
44
|
+
const truncated = lines.length > 5;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
preview: `${chalk.red(i18n.t('action.will_delete_file'))} ${filePath}\n${chalk.gray(i18n.t('status.current_content'))}\n${preview}${truncated ? '\n...' : ''}`,
|
|
48
|
+
hasContent: true,
|
|
49
|
+
action: 'delete'
|
|
50
|
+
};
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return {
|
|
53
|
+
preview: `${chalk.red('Error reading file:')} ${filePath} - ${error.message}`,
|
|
54
|
+
hasContent: false
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static async previewFileEdit(operation) {
|
|
60
|
+
const { filePath, originalContent, oldString, newString, replaceAll, edits, isMultiEdit } = operation.data;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const currentContent = await fs.readFile(filePath, 'utf8');
|
|
64
|
+
let preview = `${chalk.yellow(i18n.t('action.will_revert_file'))} ${filePath}\n\n`;
|
|
65
|
+
|
|
66
|
+
let revertedContent = currentContent;
|
|
67
|
+
|
|
68
|
+
if (originalContent) {
|
|
69
|
+
revertedContent = originalContent;
|
|
70
|
+
} else if (isMultiEdit && edits) {
|
|
71
|
+
for (let i = edits.length - 1; i >= 0; i--) {
|
|
72
|
+
const edit = edits[i];
|
|
73
|
+
if (edit.new_string && edit.old_string !== undefined) {
|
|
74
|
+
if (revertedContent.includes(edit.new_string)) {
|
|
75
|
+
revertedContent = revertedContent.replace(edit.new_string, edit.old_string);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (oldString !== undefined && newString) {
|
|
80
|
+
if (replaceAll) {
|
|
81
|
+
revertedContent = revertedContent.split(newString).join(oldString);
|
|
82
|
+
} else {
|
|
83
|
+
if (revertedContent.includes(newString)) {
|
|
84
|
+
revertedContent = revertedContent.replace(newString, oldString);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
preview += renderSideBySide(currentContent, revertedContent, { maxLines: 15 });
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
preview,
|
|
93
|
+
hasContent: true,
|
|
94
|
+
action: 'revert'
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return {
|
|
98
|
+
preview: `${chalk.yellow(i18n.t('action.will_revert_file'))} ${filePath}\n${chalk.red('Error:')} ${error.message}`,
|
|
99
|
+
hasContent: false,
|
|
100
|
+
action: 'revert'
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static async previewFileDelete(operation) {
|
|
106
|
+
const { filePath, content } = operation.data;
|
|
107
|
+
|
|
108
|
+
if (!content) {
|
|
109
|
+
return {
|
|
110
|
+
preview: `${chalk.green(i18n.t('action.will_restore_file'))} ${filePath}\n${chalk.gray(i18n.t('status.content_not_available'))}`,
|
|
111
|
+
hasContent: false
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const lines = content.split('\n');
|
|
116
|
+
const preview = lines.slice(0, 5).join('\n');
|
|
117
|
+
const truncated = lines.length > 5;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
preview: `${chalk.green(i18n.t('action.will_restore_file'))} ${filePath}\n${chalk.gray(i18n.t('status.content_to_restore'))}\n${preview}${truncated ? '\n...' : ''}`,
|
|
121
|
+
hasContent: true,
|
|
122
|
+
action: 'restore'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
static async previewFileRename(operation) {
|
|
127
|
+
const { oldPath, newPath } = operation.data;
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
preview: `${chalk.yellow(i18n.t('action.will_rename_back'))} ${newPath} → ${oldPath}`,
|
|
131
|
+
hasContent: false,
|
|
132
|
+
action: 'rename'
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static async previewDirectoryCreate(operation) {
|
|
137
|
+
const { dirPath } = operation.data;
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const exists = await fs.access(dirPath).then(() => true).catch(() => false);
|
|
141
|
+
const status = exists ? chalk.red('Will remove directory:') : chalk.gray('Directory already removed:');
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
preview: `${status} ${dirPath}`,
|
|
145
|
+
hasContent: false,
|
|
146
|
+
action: exists ? 'remove' : 'none'
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return {
|
|
150
|
+
preview: `${chalk.yellow('Will remove directory:')} ${dirPath}`,
|
|
151
|
+
hasContent: false,
|
|
152
|
+
action: 'remove'
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
static async previewDirectoryDelete(operation) {
|
|
158
|
+
const { dirPath } = operation.data;
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
preview: `${chalk.green('Will restore directory:')} ${dirPath}`,
|
|
162
|
+
hasContent: false,
|
|
163
|
+
action: 'restore'
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
static async previewBashCommand(operation) {
|
|
168
|
+
const { command } = operation.data;
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
preview: `${chalk.red(i18n.t('action.cannot_undo_bash'))} ${command}\n${chalk.gray(i18n.t('action.manual_intervention'))}`,
|
|
172
|
+
hasContent: false,
|
|
173
|
+
action: 'manual'
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { OperationType } from './Operation.js';
|
|
5
|
+
|
|
6
|
+
export class RedoManager {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.backupDir = path.join(os.homedir(), '.revert-ai', 'backups');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async init() {
|
|
12
|
+
await fs.mkdir(this.backupDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async redo(operation) {
|
|
16
|
+
switch (operation.type) {
|
|
17
|
+
case OperationType.FILE_CREATE:
|
|
18
|
+
return await this.redoFileCreate(operation);
|
|
19
|
+
case OperationType.FILE_EDIT:
|
|
20
|
+
return await this.redoFileEdit(operation);
|
|
21
|
+
case OperationType.FILE_DELETE:
|
|
22
|
+
return await this.redoFileDelete(operation);
|
|
23
|
+
case OperationType.FILE_RENAME:
|
|
24
|
+
return await this.redoFileRename(operation);
|
|
25
|
+
case OperationType.DIRECTORY_CREATE:
|
|
26
|
+
return await this.redoDirectoryCreate(operation);
|
|
27
|
+
case OperationType.DIRECTORY_DELETE:
|
|
28
|
+
return await this.redoDirectoryDelete(operation);
|
|
29
|
+
case OperationType.BASH_COMMAND:
|
|
30
|
+
return await this.redoBashCommand(operation);
|
|
31
|
+
default:
|
|
32
|
+
throw new Error(`Unknown operation type: ${operation.type}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async redoFileCreate(operation) {
|
|
37
|
+
const { filePath, content } = operation.data;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
// Check if file already exists
|
|
41
|
+
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
42
|
+
if (exists) {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
message: `Cannot redo file creation: ${filePath} already exists`
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Try to restore from backup first
|
|
50
|
+
const backupPath = path.join(this.backupDir, `${operation.id}-deleted`);
|
|
51
|
+
let fileContent = content || '';
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
fileContent = await fs.readFile(backupPath, 'utf8');
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// If no backup, use original content if available
|
|
57
|
+
if (!content) {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
message: `Cannot redo file creation: no content available for ${filePath}`
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await fs.writeFile(filePath, fileContent);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
success: true,
|
|
69
|
+
message: `File recreated: ${filePath}`
|
|
70
|
+
};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
message: `Failed to redo file creation: ${error.message}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async redoFileEdit(operation) {
|
|
80
|
+
const { filePath, originalContent, oldString, newString, replaceAll, edits, isMultiEdit } = operation.data;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const currentContent = await fs.readFile(filePath, 'utf8');
|
|
84
|
+
const backupPath = path.join(this.backupDir, `${operation.id}-redo`);
|
|
85
|
+
await fs.writeFile(backupPath, currentContent);
|
|
86
|
+
|
|
87
|
+
let redoneContent = currentContent;
|
|
88
|
+
|
|
89
|
+
if (originalContent) {
|
|
90
|
+
// This was a legacy full-content edit, but we can't safely redo it
|
|
91
|
+
// because we don't know what the "new" content should be
|
|
92
|
+
return {
|
|
93
|
+
success: false,
|
|
94
|
+
message: `Cannot redo legacy file edit: insufficient data for ${filePath}`
|
|
95
|
+
};
|
|
96
|
+
} else if (isMultiEdit && edits) {
|
|
97
|
+
// Redo MultiEdit by applying each edit in original order
|
|
98
|
+
for (const edit of edits) {
|
|
99
|
+
if (edit.old_string !== undefined && edit.new_string) {
|
|
100
|
+
if (redoneContent.includes(edit.old_string)) {
|
|
101
|
+
redoneContent = redoneContent.replace(edit.old_string, edit.new_string);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} else if (oldString !== undefined && newString) {
|
|
106
|
+
// Redo single Edit operation - apply the original string replacement
|
|
107
|
+
if (replaceAll) {
|
|
108
|
+
// Replace all occurrences
|
|
109
|
+
redoneContent = redoneContent.split(oldString).join(newString);
|
|
110
|
+
} else {
|
|
111
|
+
// Replace first occurrence only
|
|
112
|
+
if (redoneContent.includes(oldString)) {
|
|
113
|
+
redoneContent = redoneContent.replace(oldString, newString);
|
|
114
|
+
} else {
|
|
115
|
+
return {
|
|
116
|
+
success: false,
|
|
117
|
+
message: `Cannot redo edit: original string not found in ${filePath}`
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
message: `Cannot redo file edit: insufficient data for ${filePath}`
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await fs.writeFile(filePath, redoneContent);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
message: `File edit redone: ${filePath}`,
|
|
133
|
+
backupPath
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
message: `Failed to redo file edit: ${error.message}`
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async redoFileDelete(operation) {
|
|
144
|
+
const { filePath } = operation.data;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const exists = await fs.access(filePath).then(() => true).catch(() => false);
|
|
148
|
+
if (!exists) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
message: `Cannot redo file deletion: ${filePath} does not exist`
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Backup the file before deleting
|
|
156
|
+
const backupPath = path.join(this.backupDir, `${operation.id}-redo-deleted`);
|
|
157
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
158
|
+
await fs.writeFile(backupPath, content);
|
|
159
|
+
|
|
160
|
+
await fs.unlink(filePath);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
success: true,
|
|
164
|
+
message: `File deleted again: ${filePath}`,
|
|
165
|
+
backupPath
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
success: false,
|
|
170
|
+
message: `Failed to redo file deletion: ${error.message}`
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async redoFileRename(operation) {
|
|
176
|
+
const { oldPath, newPath } = operation.data;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
const oldExists = await fs.access(oldPath).then(() => true).catch(() => false);
|
|
180
|
+
const newExists = await fs.access(newPath).then(() => true).catch(() => false);
|
|
181
|
+
|
|
182
|
+
if (!oldExists) {
|
|
183
|
+
return {
|
|
184
|
+
success: false,
|
|
185
|
+
message: `Cannot redo rename: ${oldPath} does not exist`
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (newExists) {
|
|
190
|
+
return {
|
|
191
|
+
success: false,
|
|
192
|
+
message: `Cannot redo rename: ${newPath} already exists`
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await fs.rename(oldPath, newPath);
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
success: true,
|
|
200
|
+
message: `File renamed again: ${oldPath} → ${newPath}`
|
|
201
|
+
};
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
message: `Failed to redo rename: ${error.message}`
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async redoDirectoryCreate(operation) {
|
|
211
|
+
const { dirPath } = operation.data;
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const exists = await fs.access(dirPath).then(() => true).catch(() => false);
|
|
215
|
+
if (exists) {
|
|
216
|
+
return {
|
|
217
|
+
success: false,
|
|
218
|
+
message: `Cannot redo directory creation: ${dirPath} already exists`
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
success: true,
|
|
226
|
+
message: `Directory created again: ${dirPath}`
|
|
227
|
+
};
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return {
|
|
230
|
+
success: false,
|
|
231
|
+
message: `Failed to redo directory creation: ${error.message}`
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async redoDirectoryDelete(operation) {
|
|
237
|
+
const { dirPath } = operation.data;
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
const exists = await fs.access(dirPath).then(() => true).catch(() => false);
|
|
241
|
+
if (!exists) {
|
|
242
|
+
return {
|
|
243
|
+
success: false,
|
|
244
|
+
message: `Cannot redo directory deletion: ${dirPath} does not exist`
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
await fs.rmdir(dirPath);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
success: true,
|
|
252
|
+
message: `Directory deleted again: ${dirPath}`
|
|
253
|
+
};
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
message: `Failed to redo directory deletion: ${error.message}`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async redoBashCommand(operation) {
|
|
263
|
+
const { command } = operation.data;
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
success: false,
|
|
267
|
+
message: `Cannot redo bash command: ${command}\nPlease manually re-run the command.`
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { Operation, OperationType } from './Operation.js';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname } from 'path';
|
|
7
|
+
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
|
|
11
|
+
export class SessionTracker {
|
|
12
|
+
constructor(sessionId = null) {
|
|
13
|
+
this.sessionId = sessionId || new Date().toISOString().replace(/[:.]/g, '-');
|
|
14
|
+
this.operations = [];
|
|
15
|
+
this.sessionDir = path.join(os.homedir(), '.revert-ai', 'sessions');
|
|
16
|
+
this.sessionFile = path.join(this.sessionDir, `${this.sessionId}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async init() {
|
|
20
|
+
await fs.mkdir(this.sessionDir, { recursive: true });
|
|
21
|
+
await this.load();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async load() {
|
|
25
|
+
try {
|
|
26
|
+
const data = await fs.readFile(this.sessionFile, 'utf8');
|
|
27
|
+
const session = JSON.parse(data);
|
|
28
|
+
this.operations = session.operations.map(op => Operation.fromJSON(op));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error.code !== 'ENOENT') {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async save() {
|
|
37
|
+
const sessionData = {
|
|
38
|
+
sessionId: this.sessionId,
|
|
39
|
+
operations: this.operations.map(op => op.toJSON())
|
|
40
|
+
};
|
|
41
|
+
await fs.writeFile(this.sessionFile, JSON.stringify(sessionData, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async addOperation(operation) {
|
|
45
|
+
this.operations.push(operation);
|
|
46
|
+
await this.save();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async getOperations(includeUndone = false) {
|
|
50
|
+
return includeUndone
|
|
51
|
+
? this.operations
|
|
52
|
+
: this.operations.filter(op => !op.undone);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getOperation(id) {
|
|
56
|
+
return this.operations.find(op => op.id === id);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async markUndone(operationId) {
|
|
60
|
+
const operation = await this.getOperation(operationId);
|
|
61
|
+
if (operation) {
|
|
62
|
+
operation.undone = true;
|
|
63
|
+
await this.save();
|
|
64
|
+
}
|
|
65
|
+
return operation;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static async listSessions() {
|
|
69
|
+
const sessionDir = path.join(os.homedir(), '.revert-ai', 'sessions');
|
|
70
|
+
try {
|
|
71
|
+
const files = await fs.readdir(sessionDir);
|
|
72
|
+
return files
|
|
73
|
+
.filter(f => f.endsWith('.json'))
|
|
74
|
+
.map(f => f.replace('.json', ''));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (error.code === 'ENOENT') {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async getCurrentSession() {
|
|
84
|
+
const currentFile = path.join(os.homedir(), '.revert-ai', 'current-session');
|
|
85
|
+
try {
|
|
86
|
+
const sessionId = await fs.readFile(currentFile, 'utf8');
|
|
87
|
+
return sessionId.trim();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static async setCurrentSession(sessionId) {
|
|
94
|
+
const currentFile = path.join(os.homedir(), '.revert-ai', 'current-session');
|
|
95
|
+
await fs.mkdir(path.dirname(currentFile), { recursive: true });
|
|
96
|
+
await fs.writeFile(currentFile, sessionId);
|
|
97
|
+
}
|
|
98
|
+
}
|