@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,181 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, copyFile } from 'fs/promises';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
|
|
6
|
+
import type { OrphanedSession } from '../scanners/types.js';
|
|
7
|
+
import { moveToTrash, permanentDelete } from './trash.js';
|
|
8
|
+
|
|
9
|
+
export interface CleanOptions {
|
|
10
|
+
dryRun: boolean;
|
|
11
|
+
useTrash?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CleanError {
|
|
15
|
+
sessionPath: string;
|
|
16
|
+
error: Error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CleanCountByType {
|
|
20
|
+
session: number;
|
|
21
|
+
sessionEnv: number;
|
|
22
|
+
todos: number;
|
|
23
|
+
fileHistory: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CleanResult {
|
|
27
|
+
deletedCount: number;
|
|
28
|
+
deletedByType: CleanCountByType;
|
|
29
|
+
skippedCount: number;
|
|
30
|
+
alreadyGoneCount: number;
|
|
31
|
+
configEntriesRemoved: number;
|
|
32
|
+
totalSizeDeleted: number;
|
|
33
|
+
errors: CleanError[];
|
|
34
|
+
backupPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ClaudeConfig {
|
|
38
|
+
projects?: Record<string, unknown>;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getBackupDir(): string {
|
|
43
|
+
return join(homedir(), '.ai-session-tidy', 'backups');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class Cleaner {
|
|
47
|
+
async clean(
|
|
48
|
+
sessions: OrphanedSession[],
|
|
49
|
+
options: CleanOptions
|
|
50
|
+
): Promise<CleanResult> {
|
|
51
|
+
const result: CleanResult = {
|
|
52
|
+
deletedCount: 0,
|
|
53
|
+
deletedByType: {
|
|
54
|
+
session: 0,
|
|
55
|
+
sessionEnv: 0,
|
|
56
|
+
todos: 0,
|
|
57
|
+
fileHistory: 0,
|
|
58
|
+
},
|
|
59
|
+
skippedCount: 0,
|
|
60
|
+
alreadyGoneCount: 0,
|
|
61
|
+
configEntriesRemoved: 0,
|
|
62
|
+
totalSizeDeleted: 0,
|
|
63
|
+
errors: [],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const useTrash = options.useTrash ?? true;
|
|
67
|
+
|
|
68
|
+
// Separate session folders and config entries
|
|
69
|
+
const folderSessions = sessions.filter((s) => s.type !== 'config');
|
|
70
|
+
const configEntries = sessions.filter((s) => s.type === 'config');
|
|
71
|
+
|
|
72
|
+
// 1. Clean session folders/files
|
|
73
|
+
for (const session of folderSessions) {
|
|
74
|
+
if (options.dryRun) {
|
|
75
|
+
result.skippedCount++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const deleted = useTrash
|
|
81
|
+
? await moveToTrash(session.sessionPath)
|
|
82
|
+
: await permanentDelete(session.sessionPath);
|
|
83
|
+
|
|
84
|
+
if (deleted) {
|
|
85
|
+
result.deletedCount++;
|
|
86
|
+
result.totalSizeDeleted += session.size;
|
|
87
|
+
|
|
88
|
+
// Count by type
|
|
89
|
+
switch (session.type) {
|
|
90
|
+
case 'session-env':
|
|
91
|
+
result.deletedByType.sessionEnv++;
|
|
92
|
+
break;
|
|
93
|
+
case 'todos':
|
|
94
|
+
result.deletedByType.todos++;
|
|
95
|
+
break;
|
|
96
|
+
case 'file-history':
|
|
97
|
+
result.deletedByType.fileHistory++;
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
result.deletedByType.session++;
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Already deleted path - not an error
|
|
104
|
+
result.alreadyGoneCount++;
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
result.errors.push({
|
|
108
|
+
sessionPath: session.sessionPath,
|
|
109
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Clean config entries
|
|
115
|
+
if (configEntries.length > 0 && !options.dryRun) {
|
|
116
|
+
try {
|
|
117
|
+
const configResult = await this.cleanConfigEntries(configEntries);
|
|
118
|
+
result.configEntriesRemoved = configResult.removed;
|
|
119
|
+
result.backupPath = configResult.backupPath;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
result.errors.push({
|
|
122
|
+
sessionPath: configEntries[0].sessionPath,
|
|
123
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
} else if (options.dryRun) {
|
|
127
|
+
result.skippedCount += configEntries.length;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove orphaned project entries from ~/.claude.json
|
|
135
|
+
*/
|
|
136
|
+
private async cleanConfigEntries(
|
|
137
|
+
entries: OrphanedSession[]
|
|
138
|
+
): Promise<{ removed: number; backupPath: string }> {
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
return { removed: 0, backupPath: '' };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const configPath = entries[0].sessionPath;
|
|
144
|
+
const projectPathsToRemove = new Set(entries.map((e) => e.projectPath));
|
|
145
|
+
|
|
146
|
+
// Create backup directory
|
|
147
|
+
const backupDir = getBackupDir();
|
|
148
|
+
if (!existsSync(backupDir)) {
|
|
149
|
+
await mkdir(backupDir, { recursive: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Create backup file (with timestamp)
|
|
153
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
154
|
+
const backupPath = join(backupDir, `claude.json.${timestamp}.bak`);
|
|
155
|
+
await copyFile(configPath, backupPath);
|
|
156
|
+
|
|
157
|
+
// Read config file
|
|
158
|
+
const content = await readFile(configPath, 'utf-8');
|
|
159
|
+
const config: ClaudeConfig = JSON.parse(content);
|
|
160
|
+
|
|
161
|
+
if (!config.projects) {
|
|
162
|
+
return { removed: 0, backupPath };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Remove orphaned project entries
|
|
166
|
+
let removedCount = 0;
|
|
167
|
+
for (const projectPath of projectPathsToRemove) {
|
|
168
|
+
if (projectPath in config.projects) {
|
|
169
|
+
delete config.projects[projectPath];
|
|
170
|
+
removedCount++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Save modified config
|
|
175
|
+
if (removedCount > 0) {
|
|
176
|
+
await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { removed: removedCount, backupPath };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { homedir } from 'os';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { readFile, writeFile, unlink, mkdir } from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
|
|
7
|
+
const SERVICE_LABEL = 'sooink.ai-session-tidy.watcher';
|
|
8
|
+
const PLIST_FILENAME = `${SERVICE_LABEL}.plist`;
|
|
9
|
+
|
|
10
|
+
export type ServiceStatus = 'running' | 'stopped' | 'not_installed';
|
|
11
|
+
|
|
12
|
+
export interface ServiceInfo {
|
|
13
|
+
status: ServiceStatus;
|
|
14
|
+
pid?: number;
|
|
15
|
+
label: string;
|
|
16
|
+
plistPath: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getPlistPath(): string {
|
|
20
|
+
return join(homedir(), 'Library', 'LaunchAgents', PLIST_FILENAME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getNodePath(): string {
|
|
24
|
+
// Get absolute path to node executable
|
|
25
|
+
return process.execPath;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getScriptPath(): string {
|
|
29
|
+
// Get absolute path to the CLI script
|
|
30
|
+
const scriptPath = process.argv[1];
|
|
31
|
+
if (scriptPath && existsSync(scriptPath)) {
|
|
32
|
+
return scriptPath;
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Could not determine script path');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function generatePlist(options: {
|
|
38
|
+
label: string;
|
|
39
|
+
nodePath: string;
|
|
40
|
+
scriptPath: string;
|
|
41
|
+
args: string[];
|
|
42
|
+
}): string {
|
|
43
|
+
const allArgs = [options.nodePath, options.scriptPath, ...options.args];
|
|
44
|
+
const argsXml = allArgs.map((arg) => ` <string>${arg}</string>`).join('\n');
|
|
45
|
+
|
|
46
|
+
const home = homedir();
|
|
47
|
+
|
|
48
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
49
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
50
|
+
<plist version="1.0">
|
|
51
|
+
<dict>
|
|
52
|
+
<key>Label</key>
|
|
53
|
+
<string>${options.label}</string>
|
|
54
|
+
<key>EnvironmentVariables</key>
|
|
55
|
+
<dict>
|
|
56
|
+
<key>HOME</key>
|
|
57
|
+
<string>${home}</string>
|
|
58
|
+
</dict>
|
|
59
|
+
<key>ProgramArguments</key>
|
|
60
|
+
<array>
|
|
61
|
+
${argsXml}
|
|
62
|
+
</array>
|
|
63
|
+
<key>RunAtLoad</key>
|
|
64
|
+
<true/>
|
|
65
|
+
<key>KeepAlive</key>
|
|
66
|
+
<true/>
|
|
67
|
+
<key>StandardOutPath</key>
|
|
68
|
+
<string>${join(home, '.ai-session-tidy', 'watcher.log')}</string>
|
|
69
|
+
<key>StandardErrorPath</key>
|
|
70
|
+
<string>${join(home, '.ai-session-tidy', 'watcher.error.log')}</string>
|
|
71
|
+
</dict>
|
|
72
|
+
</plist>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ServiceManager {
|
|
76
|
+
private readonly plistPath: string;
|
|
77
|
+
|
|
78
|
+
constructor() {
|
|
79
|
+
this.plistPath = getPlistPath();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
isSupported(): boolean {
|
|
83
|
+
return process.platform === 'darwin';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async install(): Promise<void> {
|
|
87
|
+
if (!this.isSupported()) {
|
|
88
|
+
throw new Error('Service management is only supported on macOS');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ensure LaunchAgents directory exists
|
|
92
|
+
const launchAgentsDir = dirname(this.plistPath);
|
|
93
|
+
if (!existsSync(launchAgentsDir)) {
|
|
94
|
+
await mkdir(launchAgentsDir, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Ensure log directory exists and clear old logs
|
|
98
|
+
const logDir = join(homedir(), '.ai-session-tidy');
|
|
99
|
+
if (!existsSync(logDir)) {
|
|
100
|
+
await mkdir(logDir, { recursive: true });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Clear old log files
|
|
104
|
+
const stdoutPath = join(logDir, 'watcher.log');
|
|
105
|
+
const stderrPath = join(logDir, 'watcher.error.log');
|
|
106
|
+
await writeFile(stdoutPath, '', 'utf-8');
|
|
107
|
+
await writeFile(stderrPath, '', 'utf-8');
|
|
108
|
+
|
|
109
|
+
const plistContent = generatePlist({
|
|
110
|
+
label: SERVICE_LABEL,
|
|
111
|
+
nodePath: getNodePath(),
|
|
112
|
+
scriptPath: getScriptPath(),
|
|
113
|
+
args: ['watch', 'run'],
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await writeFile(this.plistPath, plistContent, 'utf-8');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async uninstall(): Promise<void> {
|
|
120
|
+
if (existsSync(this.plistPath)) {
|
|
121
|
+
await unlink(this.plistPath);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async start(): Promise<void> {
|
|
126
|
+
if (!this.isSupported()) {
|
|
127
|
+
throw new Error('Service management is only supported on macOS');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!existsSync(this.plistPath)) {
|
|
131
|
+
throw new Error('Service not installed. Run "watch start" to install and start.');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
execSync(`launchctl load "${this.plistPath}"`, { stdio: 'pipe' });
|
|
136
|
+
} catch (error) {
|
|
137
|
+
// Already loaded is not an error
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
if (!message.includes('already loaded')) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async stop(): Promise<void> {
|
|
146
|
+
if (!this.isSupported()) {
|
|
147
|
+
throw new Error('Service management is only supported on macOS');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!existsSync(this.plistPath)) {
|
|
151
|
+
return; // Nothing to stop
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
execSync(`launchctl unload "${this.plistPath}"`, { stdio: 'pipe' });
|
|
156
|
+
} catch {
|
|
157
|
+
// Ignore errors when stopping (might not be running)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async status(): Promise<ServiceInfo> {
|
|
162
|
+
const info: ServiceInfo = {
|
|
163
|
+
status: 'not_installed',
|
|
164
|
+
label: SERVICE_LABEL,
|
|
165
|
+
plistPath: this.plistPath,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (!this.isSupported()) {
|
|
169
|
+
return info;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!existsSync(this.plistPath)) {
|
|
173
|
+
return info;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
|
178
|
+
const lines = output.split('\n');
|
|
179
|
+
|
|
180
|
+
for (const line of lines) {
|
|
181
|
+
if (line.includes(SERVICE_LABEL)) {
|
|
182
|
+
const parts = line.split(/\s+/);
|
|
183
|
+
const pid = parseInt(parts[0] ?? '', 10);
|
|
184
|
+
|
|
185
|
+
if (!isNaN(pid) && pid > 0) {
|
|
186
|
+
info.status = 'running';
|
|
187
|
+
info.pid = pid;
|
|
188
|
+
} else {
|
|
189
|
+
info.status = 'stopped';
|
|
190
|
+
}
|
|
191
|
+
return info;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// plist exists but not loaded
|
|
196
|
+
info.status = 'stopped';
|
|
197
|
+
return info;
|
|
198
|
+
} catch {
|
|
199
|
+
info.status = 'stopped';
|
|
200
|
+
return info;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async getLogs(lines: number = 50): Promise<{ stdout: string; stderr: string }> {
|
|
205
|
+
const logDir = join(homedir(), '.ai-session-tidy');
|
|
206
|
+
const stdoutPath = join(logDir, 'watcher.log');
|
|
207
|
+
const stderrPath = join(logDir, 'watcher.error.log');
|
|
208
|
+
|
|
209
|
+
let stdout = '';
|
|
210
|
+
let stderr = '';
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (existsSync(stdoutPath)) {
|
|
214
|
+
const content = await readFile(stdoutPath, 'utf-8');
|
|
215
|
+
const logLines = content.split('\n');
|
|
216
|
+
stdout = logLines.slice(-lines).join('\n');
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
// Ignore read errors
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (existsSync(stderrPath)) {
|
|
224
|
+
const content = await readFile(stderrPath, 'utf-8');
|
|
225
|
+
const logLines = content.split('\n');
|
|
226
|
+
stderr = logLines.slice(-lines).join('\n');
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// Ignore read errors
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { stdout, stderr };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export const serviceManager = new ServiceManager();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdir, writeFile, rm, access } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
import { moveToTrash, permanentDelete } from './trash.js';
|
|
7
|
+
|
|
8
|
+
describe('moveToTrash', () => {
|
|
9
|
+
const testDir = join(tmpdir(), 'trashtest' + Date.now());
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
await mkdir(testDir, { recursive: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await rm(testDir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('moves file to trash', async () => {
|
|
20
|
+
const filePath = join(testDir, 'testfile.txt');
|
|
21
|
+
await writeFile(filePath, 'test content');
|
|
22
|
+
|
|
23
|
+
await moveToTrash(filePath);
|
|
24
|
+
|
|
25
|
+
// File should no longer exist
|
|
26
|
+
await expect(access(filePath)).rejects.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('moves directory to trash', async () => {
|
|
30
|
+
const dirPath = join(testDir, 'testdir');
|
|
31
|
+
await mkdir(dirPath);
|
|
32
|
+
await writeFile(join(dirPath, 'file.txt'), 'content');
|
|
33
|
+
|
|
34
|
+
await moveToTrash(dirPath);
|
|
35
|
+
|
|
36
|
+
// Directory should no longer exist
|
|
37
|
+
await expect(access(dirPath)).rejects.toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns false for non-existent path (silent skip)', async () => {
|
|
41
|
+
const nonExistent = join(testDir, 'nonexistent12345');
|
|
42
|
+
|
|
43
|
+
const result = await moveToTrash(nonExistent);
|
|
44
|
+
expect(result).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('returns true on successful file deletion', async () => {
|
|
48
|
+
const filePath = join(testDir, 'testfile2.txt');
|
|
49
|
+
await writeFile(filePath, 'test content');
|
|
50
|
+
|
|
51
|
+
const result = await moveToTrash(filePath);
|
|
52
|
+
expect(result).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('permanentDelete', () => {
|
|
57
|
+
const testDir = join(tmpdir(), 'delettest' + Date.now());
|
|
58
|
+
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
await mkdir(testDir, { recursive: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(async () => {
|
|
64
|
+
await rm(testDir, { recursive: true, force: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('permanently deletes file', async () => {
|
|
68
|
+
const filePath = join(testDir, 'todelete.txt');
|
|
69
|
+
await writeFile(filePath, 'to be deleted');
|
|
70
|
+
|
|
71
|
+
await permanentDelete(filePath);
|
|
72
|
+
|
|
73
|
+
await expect(access(filePath)).rejects.toThrow();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('permanently deletes directory (recursive)', async () => {
|
|
77
|
+
const dirPath = join(testDir, 'todelete');
|
|
78
|
+
await mkdir(dirPath);
|
|
79
|
+
await writeFile(join(dirPath, 'file.txt'), 'content');
|
|
80
|
+
|
|
81
|
+
await permanentDelete(dirPath);
|
|
82
|
+
|
|
83
|
+
await expect(access(dirPath)).rejects.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('returns false for non-existent path (silent skip)', async () => {
|
|
87
|
+
const nonExistent = join(testDir, 'nonexistent12345');
|
|
88
|
+
|
|
89
|
+
const result = await permanentDelete(nonExistent);
|
|
90
|
+
expect(result).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns true on successful file deletion', async () => {
|
|
94
|
+
const filePath = join(testDir, 'todelete2.txt');
|
|
95
|
+
await writeFile(filePath, 'to be deleted');
|
|
96
|
+
|
|
97
|
+
const result = await permanentDelete(filePath);
|
|
98
|
+
expect(result).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { rm, access } from 'fs/promises';
|
|
2
|
+
import trash from 'trash';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if path exists
|
|
6
|
+
*/
|
|
7
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
8
|
+
try {
|
|
9
|
+
await access(path);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Move file or directory to trash
|
|
18
|
+
* @returns true if deleted, false if already gone
|
|
19
|
+
*/
|
|
20
|
+
export async function moveToTrash(path: string): Promise<boolean> {
|
|
21
|
+
if (!(await pathExists(path))) {
|
|
22
|
+
return false; // Already deleted - silent skip
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await trash(path);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Permanently delete file or directory
|
|
31
|
+
* @returns true if deleted, false if already gone
|
|
32
|
+
*/
|
|
33
|
+
export async function permanentDelete(path: string): Promise<boolean> {
|
|
34
|
+
if (!(await pathExists(path))) {
|
|
35
|
+
return false; // Already deleted - silent skip
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await rm(path, { recursive: true, force: true });
|
|
39
|
+
return true;
|
|
40
|
+
}
|