@sooink/ai-session-tidy 0.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/.prettierrc +7 -0
- package/LICENSE +21 -0
- package/README.ko.md +171 -0
- package/README.md +169 -0
- package/assets/demo-interactive.gif +0 -0
- package/assets/demo.gif +0 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1917 -0
- package/dist/index.js.map +1 -0
- package/eslint.config.js +29 -0
- package/package.json +54 -0
- package/src/cli.ts +21 -0
- package/src/commands/clean.ts +335 -0
- package/src/commands/config.ts +144 -0
- package/src/commands/list.ts +86 -0
- package/src/commands/scan.ts +200 -0
- package/src/commands/watch.ts +359 -0
- package/src/core/cleaner.test.ts +125 -0
- package/src/core/cleaner.ts +181 -0
- package/src/core/service.ts +236 -0
- package/src/core/trash.test.ts +100 -0
- package/src/core/trash.ts +40 -0
- package/src/core/watcher.test.ts +210 -0
- package/src/core/watcher.ts +194 -0
- package/src/index.ts +5 -0
- package/src/scanners/claude-code.test.ts +112 -0
- package/src/scanners/claude-code.ts +452 -0
- package/src/scanners/cursor.test.ts +140 -0
- package/src/scanners/cursor.ts +133 -0
- package/src/scanners/index.ts +39 -0
- package/src/scanners/types.ts +34 -0
- package/src/utils/config.ts +132 -0
- package/src/utils/logger.ts +29 -0
- package/src/utils/paths.test.ts +95 -0
- package/src/utils/paths.ts +92 -0
- package/src/utils/size.test.ts +80 -0
- package/src/utils/size.ts +50 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import { readdir, readFile, stat, access } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { createReadStream } from 'fs';
|
|
4
|
+
import { createInterface } from 'readline';
|
|
5
|
+
|
|
6
|
+
import type { Scanner, ScanResult, OrphanedSession } from './types.js';
|
|
7
|
+
import {
|
|
8
|
+
decodePath,
|
|
9
|
+
getClaudeProjectsDir,
|
|
10
|
+
getClaudeConfigPath,
|
|
11
|
+
getClaudeSessionEnvDir,
|
|
12
|
+
getClaudeTodosDir,
|
|
13
|
+
getClaudeFileHistoryDir,
|
|
14
|
+
} from '../utils/paths.js';
|
|
15
|
+
import { getDirectorySize } from '../utils/size.js';
|
|
16
|
+
|
|
17
|
+
interface ClaudeProjectData {
|
|
18
|
+
lastCost?: number;
|
|
19
|
+
lastTotalInputTokens?: number;
|
|
20
|
+
lastTotalOutputTokens?: number;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ClaudeConfig {
|
|
25
|
+
projects?: Record<string, ClaudeProjectData>;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface SessionEntry {
|
|
30
|
+
cwd?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ClaudeCodeScannerOptions {
|
|
34
|
+
projectsDir?: string;
|
|
35
|
+
configPath?: string | null; // null to disable config scanning
|
|
36
|
+
sessionEnvDir?: string | null; // null to disable session-env scanning
|
|
37
|
+
todosDir?: string | null; // null to disable todos scanning
|
|
38
|
+
fileHistoryDir?: string | null; // null to disable file-history scanning
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class ClaudeCodeScanner implements Scanner {
|
|
42
|
+
readonly name = 'claude-code' as const;
|
|
43
|
+
private readonly projectsDir: string;
|
|
44
|
+
private readonly configPath: string | null;
|
|
45
|
+
private readonly sessionEnvDir: string | null;
|
|
46
|
+
private readonly todosDir: string | null;
|
|
47
|
+
private readonly fileHistoryDir: string | null;
|
|
48
|
+
|
|
49
|
+
constructor(projectsDirOrOptions?: string | ClaudeCodeScannerOptions) {
|
|
50
|
+
if (typeof projectsDirOrOptions === 'string') {
|
|
51
|
+
// Backward compatibility: treat string as projectsDir, disable other scans
|
|
52
|
+
this.projectsDir = projectsDirOrOptions;
|
|
53
|
+
this.configPath = null;
|
|
54
|
+
this.sessionEnvDir = null;
|
|
55
|
+
this.todosDir = null;
|
|
56
|
+
this.fileHistoryDir = null;
|
|
57
|
+
} else if (projectsDirOrOptions) {
|
|
58
|
+
this.projectsDir = projectsDirOrOptions.projectsDir ?? getClaudeProjectsDir();
|
|
59
|
+
this.configPath = projectsDirOrOptions.configPath === undefined
|
|
60
|
+
? getClaudeConfigPath()
|
|
61
|
+
: projectsDirOrOptions.configPath;
|
|
62
|
+
this.sessionEnvDir = projectsDirOrOptions.sessionEnvDir === undefined
|
|
63
|
+
? getClaudeSessionEnvDir()
|
|
64
|
+
: projectsDirOrOptions.sessionEnvDir;
|
|
65
|
+
this.todosDir = projectsDirOrOptions.todosDir === undefined
|
|
66
|
+
? getClaudeTodosDir()
|
|
67
|
+
: projectsDirOrOptions.todosDir;
|
|
68
|
+
this.fileHistoryDir = projectsDirOrOptions.fileHistoryDir === undefined
|
|
69
|
+
? getClaudeFileHistoryDir()
|
|
70
|
+
: projectsDirOrOptions.fileHistoryDir;
|
|
71
|
+
} else {
|
|
72
|
+
this.projectsDir = getClaudeProjectsDir();
|
|
73
|
+
this.configPath = getClaudeConfigPath();
|
|
74
|
+
this.sessionEnvDir = getClaudeSessionEnvDir();
|
|
75
|
+
this.todosDir = getClaudeTodosDir();
|
|
76
|
+
this.fileHistoryDir = getClaudeFileHistoryDir();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async isAvailable(): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await access(this.projectsDir);
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async scan(): Promise<ScanResult> {
|
|
90
|
+
const startTime = performance.now();
|
|
91
|
+
const sessions: OrphanedSession[] = [];
|
|
92
|
+
|
|
93
|
+
// 1. Scan session folders
|
|
94
|
+
if (await this.isAvailable()) {
|
|
95
|
+
const entries = await readdir(this.projectsDir, { withFileTypes: true });
|
|
96
|
+
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (!entry.isDirectory()) continue;
|
|
99
|
+
|
|
100
|
+
const sessionPath = join(this.projectsDir, entry.name);
|
|
101
|
+
|
|
102
|
+
// Extract actual project path from JSONL file
|
|
103
|
+
let projectPath = await this.extractProjectPath(sessionPath);
|
|
104
|
+
|
|
105
|
+
// Fallback to decoding if path not found in JSONL
|
|
106
|
+
if (!projectPath) {
|
|
107
|
+
projectPath = decodePath(entry.name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Check if original project exists
|
|
111
|
+
const projectExists = await this.pathExists(projectPath);
|
|
112
|
+
if (projectExists) continue;
|
|
113
|
+
|
|
114
|
+
// Exclude empty directories
|
|
115
|
+
const size = await getDirectorySize(sessionPath);
|
|
116
|
+
if (size === 0) continue;
|
|
117
|
+
|
|
118
|
+
// Get last modified time
|
|
119
|
+
const lastModified = await this.getLastModified(sessionPath);
|
|
120
|
+
|
|
121
|
+
sessions.push({
|
|
122
|
+
toolName: this.name,
|
|
123
|
+
sessionPath,
|
|
124
|
+
projectPath,
|
|
125
|
+
size,
|
|
126
|
+
lastModified,
|
|
127
|
+
type: 'session',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Scan ~/.claude.json config file
|
|
133
|
+
const configSessions = await this.scanConfigFile();
|
|
134
|
+
sessions.push(...configSessions);
|
|
135
|
+
|
|
136
|
+
// 3. Scan ~/.claude/session-env empty folders
|
|
137
|
+
const sessionEnvSessions = await this.scanSessionEnvDir();
|
|
138
|
+
sessions.push(...sessionEnvSessions);
|
|
139
|
+
|
|
140
|
+
// 4. Collect valid session UUIDs then scan todos/file-history
|
|
141
|
+
const validSessionIds = await this.collectValidSessionIds();
|
|
142
|
+
|
|
143
|
+
// 5. Scan ~/.claude/todos for orphaned files
|
|
144
|
+
const todosSessions = await this.scanTodosDir(validSessionIds);
|
|
145
|
+
sessions.push(...todosSessions);
|
|
146
|
+
|
|
147
|
+
// 6. Scan ~/.claude/file-history for orphaned folders
|
|
148
|
+
const fileHistorySessions = await this.scanFileHistoryDir(validSessionIds);
|
|
149
|
+
sessions.push(...fileHistorySessions);
|
|
150
|
+
|
|
151
|
+
const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
toolName: this.name,
|
|
155
|
+
sessions,
|
|
156
|
+
totalSize,
|
|
157
|
+
scanDuration: performance.now() - startTime,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Detect orphaned projects from ~/.claude.json projects entries
|
|
163
|
+
*/
|
|
164
|
+
private async scanConfigFile(): Promise<OrphanedSession[]> {
|
|
165
|
+
if (!this.configPath) {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const configPath = this.configPath;
|
|
170
|
+
const orphanedConfigs: OrphanedSession[] = [];
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const content = await readFile(configPath, 'utf-8');
|
|
174
|
+
const config: ClaudeConfig = JSON.parse(content);
|
|
175
|
+
const configStat = await stat(configPath);
|
|
176
|
+
|
|
177
|
+
if (!config.projects || typeof config.projects !== 'object') {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
for (const [projectPath, projectData] of Object.entries(config.projects)) {
|
|
182
|
+
const projectExists = await this.pathExists(projectPath);
|
|
183
|
+
if (!projectExists) {
|
|
184
|
+
orphanedConfigs.push({
|
|
185
|
+
toolName: this.name,
|
|
186
|
+
sessionPath: configPath,
|
|
187
|
+
projectPath,
|
|
188
|
+
size: 0, // config entries have negligible size
|
|
189
|
+
lastModified: configStat.mtime,
|
|
190
|
+
type: 'config',
|
|
191
|
+
configStats: {
|
|
192
|
+
lastCost: projectData.lastCost,
|
|
193
|
+
lastTotalInputTokens: projectData.lastTotalInputTokens,
|
|
194
|
+
lastTotalOutputTokens: projectData.lastTotalOutputTokens,
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// Ignore if config file doesn't exist or read fails
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return orphanedConfigs;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Detect empty session environment folders from ~/.claude/session-env
|
|
208
|
+
*/
|
|
209
|
+
private async scanSessionEnvDir(): Promise<OrphanedSession[]> {
|
|
210
|
+
if (!this.sessionEnvDir) {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const orphanedEnvs: OrphanedSession[] = [];
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
await access(this.sessionEnvDir);
|
|
218
|
+
const entries = await readdir(this.sessionEnvDir, { withFileTypes: true });
|
|
219
|
+
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (!entry.isDirectory()) continue;
|
|
222
|
+
|
|
223
|
+
const envPath = join(this.sessionEnvDir, entry.name);
|
|
224
|
+
const files = await readdir(envPath);
|
|
225
|
+
|
|
226
|
+
// Only treat empty folders as orphaned
|
|
227
|
+
if (files.length === 0) {
|
|
228
|
+
const envStat = await stat(envPath);
|
|
229
|
+
orphanedEnvs.push({
|
|
230
|
+
toolName: this.name,
|
|
231
|
+
sessionPath: envPath,
|
|
232
|
+
projectPath: entry.name, // UUID
|
|
233
|
+
size: 0,
|
|
234
|
+
lastModified: envStat.mtime,
|
|
235
|
+
type: 'session-env',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Ignore if directory doesn't exist or access fails
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return orphanedEnvs;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Collect valid session UUIDs from all projects
|
|
248
|
+
*/
|
|
249
|
+
private async collectValidSessionIds(): Promise<Set<string>> {
|
|
250
|
+
const sessionIds = new Set<string>();
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const projectDirs = await readdir(this.projectsDir, { withFileTypes: true });
|
|
254
|
+
|
|
255
|
+
for (const projectDir of projectDirs) {
|
|
256
|
+
if (!projectDir.isDirectory()) continue;
|
|
257
|
+
|
|
258
|
+
const projectPath = join(this.projectsDir, projectDir.name);
|
|
259
|
+
const files = await readdir(projectPath);
|
|
260
|
+
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
if (file.endsWith('.jsonl')) {
|
|
263
|
+
// Extract UUID from UUID.jsonl
|
|
264
|
+
const sessionId = file.replace('.jsonl', '');
|
|
265
|
+
sessionIds.add(sessionId);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// Return empty Set if directory access fails
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return sessionIds;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Detect orphaned todo files from ~/.claude/todos
|
|
278
|
+
* Filename pattern: {session-uuid}-agent-{agent-uuid}.json
|
|
279
|
+
*/
|
|
280
|
+
private async scanTodosDir(validSessionIds: Set<string>): Promise<OrphanedSession[]> {
|
|
281
|
+
if (!this.todosDir) {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const orphanedTodos: OrphanedSession[] = [];
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
await access(this.todosDir);
|
|
289
|
+
const entries = await readdir(this.todosDir, { withFileTypes: true });
|
|
290
|
+
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
293
|
+
|
|
294
|
+
// Extract session UUID from filename (first UUID)
|
|
295
|
+
const sessionId = entry.name.split('-agent-')[0];
|
|
296
|
+
if (!sessionId) continue;
|
|
297
|
+
|
|
298
|
+
// Orphan if not in valid session IDs
|
|
299
|
+
if (!validSessionIds.has(sessionId)) {
|
|
300
|
+
const todoPath = join(this.todosDir, entry.name);
|
|
301
|
+
const todoStat = await stat(todoPath);
|
|
302
|
+
|
|
303
|
+
orphanedTodos.push({
|
|
304
|
+
toolName: this.name,
|
|
305
|
+
sessionPath: todoPath,
|
|
306
|
+
projectPath: sessionId, // Session UUID
|
|
307
|
+
size: todoStat.size,
|
|
308
|
+
lastModified: todoStat.mtime,
|
|
309
|
+
type: 'todos',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Ignore if directory doesn't exist or access fails
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return orphanedTodos;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Detect orphaned folders from ~/.claude/file-history
|
|
322
|
+
* Folder name is the session UUID
|
|
323
|
+
*/
|
|
324
|
+
private async scanFileHistoryDir(validSessionIds: Set<string>): Promise<OrphanedSession[]> {
|
|
325
|
+
if (!this.fileHistoryDir) {
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const orphanedHistories: OrphanedSession[] = [];
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
await access(this.fileHistoryDir);
|
|
333
|
+
const entries = await readdir(this.fileHistoryDir, { withFileTypes: true });
|
|
334
|
+
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
if (!entry.isDirectory()) continue;
|
|
337
|
+
|
|
338
|
+
const sessionId = entry.name;
|
|
339
|
+
|
|
340
|
+
// Orphan if not in valid session IDs
|
|
341
|
+
if (!validSessionIds.has(sessionId)) {
|
|
342
|
+
const historyPath = join(this.fileHistoryDir, entry.name);
|
|
343
|
+
const size = await getDirectorySize(historyPath);
|
|
344
|
+
const historyStat = await stat(historyPath);
|
|
345
|
+
|
|
346
|
+
orphanedHistories.push({
|
|
347
|
+
toolName: this.name,
|
|
348
|
+
sessionPath: historyPath,
|
|
349
|
+
projectPath: sessionId, // Session UUID
|
|
350
|
+
size,
|
|
351
|
+
lastModified: historyStat.mtime,
|
|
352
|
+
type: 'file-history',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// Ignore if directory doesn't exist or access fails
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return orphanedHistories;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Extract project path (cwd) from JSONL file
|
|
365
|
+
*/
|
|
366
|
+
private async extractProjectPath(sessionDir: string): Promise<string | null> {
|
|
367
|
+
try {
|
|
368
|
+
const files = await readdir(sessionDir);
|
|
369
|
+
const jsonlFile = files.find((f) => f.endsWith('.jsonl'));
|
|
370
|
+
|
|
371
|
+
if (!jsonlFile) return null;
|
|
372
|
+
|
|
373
|
+
const jsonlPath = join(sessionDir, jsonlFile);
|
|
374
|
+
|
|
375
|
+
// Read only first few lines to find cwd
|
|
376
|
+
const cwd = await this.findCwdInJsonl(jsonlPath);
|
|
377
|
+
return cwd;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private async findCwdInJsonl(jsonlPath: string): Promise<string | null> {
|
|
384
|
+
return new Promise((resolve) => {
|
|
385
|
+
const stream = createReadStream(jsonlPath, { encoding: 'utf-8' });
|
|
386
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
387
|
+
|
|
388
|
+
let found = false;
|
|
389
|
+
let lineCount = 0;
|
|
390
|
+
const maxLines = 10; // Check only first 10 lines
|
|
391
|
+
|
|
392
|
+
rl.on('line', (line) => {
|
|
393
|
+
if (found || lineCount >= maxLines) {
|
|
394
|
+
rl.close();
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
lineCount++;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const entry: SessionEntry = JSON.parse(line);
|
|
402
|
+
if (entry.cwd) {
|
|
403
|
+
found = true;
|
|
404
|
+
rl.close();
|
|
405
|
+
stream.destroy();
|
|
406
|
+
resolve(entry.cwd);
|
|
407
|
+
}
|
|
408
|
+
} catch {
|
|
409
|
+
// Ignore JSON parse failures
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
rl.on('close', () => {
|
|
414
|
+
if (!found) resolve(null);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
rl.on('error', () => {
|
|
418
|
+
resolve(null);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async pathExists(path: string): Promise<boolean> {
|
|
424
|
+
try {
|
|
425
|
+
await access(path);
|
|
426
|
+
return true;
|
|
427
|
+
} catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private async getLastModified(dirPath: string): Promise<Date> {
|
|
433
|
+
try {
|
|
434
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
435
|
+
let latestTime = 0;
|
|
436
|
+
|
|
437
|
+
for (const entry of entries) {
|
|
438
|
+
const fullPath = join(dirPath, entry.name);
|
|
439
|
+
const fileStat = await stat(fullPath);
|
|
440
|
+
const mtime = fileStat.mtimeMs;
|
|
441
|
+
|
|
442
|
+
if (mtime > latestTime) {
|
|
443
|
+
latestTime = mtime;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return latestTime > 0 ? new Date(latestTime) : new Date();
|
|
448
|
+
} catch {
|
|
449
|
+
return new Date();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
import { CursorScanner } from './cursor.js';
|
|
7
|
+
|
|
8
|
+
describe('CursorScanner', () => {
|
|
9
|
+
const testBaseDir = join(tmpdir(), 'cursortest' + Date.now());
|
|
10
|
+
const mockWorkspaceDir = join(testBaseDir, 'workspaceStorage');
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await mkdir(mockWorkspaceDir, { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await rm(testBaseDir, { recursive: true, force: true });
|
|
18
|
+
vi.restoreAllMocks();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('isAvailable', () => {
|
|
22
|
+
it('returns true when workspaceStorage directory exists', async () => {
|
|
23
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
24
|
+
expect(await scanner.isAvailable()).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('returns false when directory does not exist', async () => {
|
|
28
|
+
const scanner = new CursorScanner('/nonexistent/path/12345');
|
|
29
|
+
expect(await scanner.isAvailable()).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('scan', () => {
|
|
34
|
+
it('parses project path from workspace.json', async () => {
|
|
35
|
+
const workspaceHash = 'abc123';
|
|
36
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
37
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
// Create workspace.json (deleted project path)
|
|
40
|
+
const workspaceJson = {
|
|
41
|
+
folder: 'file:///deleted/project/path',
|
|
42
|
+
};
|
|
43
|
+
await writeFile(
|
|
44
|
+
join(workspaceDir, 'workspace.json'),
|
|
45
|
+
JSON.stringify(workspaceJson)
|
|
46
|
+
);
|
|
47
|
+
// Add other files (for size calculation test)
|
|
48
|
+
await writeFile(join(workspaceDir, 'state.vscdb'), 'database content');
|
|
49
|
+
|
|
50
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
51
|
+
const result = await scanner.scan();
|
|
52
|
+
|
|
53
|
+
expect(result.sessions).toHaveLength(1);
|
|
54
|
+
expect(result.sessions[0].projectPath).toBe('/deleted/project/path');
|
|
55
|
+
expect(result.sessions[0].toolName).toBe('cursor');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('excludes active projects (original exists)', async () => {
|
|
59
|
+
// Create actually existing directory
|
|
60
|
+
const realProjectDir = join(testBaseDir, 'existingproject');
|
|
61
|
+
await mkdir(realProjectDir, { recursive: true });
|
|
62
|
+
|
|
63
|
+
const workspaceHash = 'def456';
|
|
64
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
65
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
const workspaceJson = {
|
|
68
|
+
folder: `file://${realProjectDir}`,
|
|
69
|
+
};
|
|
70
|
+
await writeFile(
|
|
71
|
+
join(workspaceDir, 'workspace.json'),
|
|
72
|
+
JSON.stringify(workspaceJson)
|
|
73
|
+
);
|
|
74
|
+
await writeFile(join(workspaceDir, 'state.vscdb'), 'data');
|
|
75
|
+
|
|
76
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
77
|
+
const result = await scanner.scan();
|
|
78
|
+
|
|
79
|
+
expect(result.sessions).toHaveLength(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('ignores directories without workspace.json', async () => {
|
|
83
|
+
const workspaceHash = 'noworkspace';
|
|
84
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
85
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
86
|
+
await writeFile(join(workspaceDir, 'other.txt'), 'content');
|
|
87
|
+
|
|
88
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
89
|
+
const result = await scanner.scan();
|
|
90
|
+
|
|
91
|
+
expect(result.sessions).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('ignores invalid workspace.json', async () => {
|
|
95
|
+
const workspaceHash = 'badjson';
|
|
96
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
97
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
98
|
+
await writeFile(join(workspaceDir, 'workspace.json'), 'invalid json{');
|
|
99
|
+
|
|
100
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
101
|
+
const result = await scanner.scan();
|
|
102
|
+
|
|
103
|
+
expect(result.sessions).toHaveLength(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('ignores workspace.json without folder field', async () => {
|
|
107
|
+
const workspaceHash = 'nofolder';
|
|
108
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
109
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(workspaceDir, 'workspace.json'),
|
|
112
|
+
JSON.stringify({ workspace: 'something' })
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
116
|
+
const result = await scanner.scan();
|
|
117
|
+
|
|
118
|
+
expect(result.sessions).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('calculates session size and modified time', async () => {
|
|
122
|
+
const workspaceHash = 'sizetest';
|
|
123
|
+
const workspaceDir = join(mockWorkspaceDir, workspaceHash);
|
|
124
|
+
await mkdir(workspaceDir, { recursive: true });
|
|
125
|
+
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(workspaceDir, 'workspace.json'),
|
|
128
|
+
JSON.stringify({ folder: 'file:///orphan/project' })
|
|
129
|
+
);
|
|
130
|
+
await writeFile(join(workspaceDir, 'data.db'), 'a'.repeat(1000));
|
|
131
|
+
|
|
132
|
+
const scanner = new CursorScanner(mockWorkspaceDir);
|
|
133
|
+
const result = await scanner.scan();
|
|
134
|
+
|
|
135
|
+
expect(result.sessions[0].size).toBeGreaterThan(0);
|
|
136
|
+
expect(result.sessions[0].lastModified).toBeInstanceOf(Date);
|
|
137
|
+
expect(result.totalSize).toBe(result.sessions[0].size);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|