appclean 1.8.0
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/.github/workflows/publish.yml +41 -0
- package/.github/workflows/test.yml +37 -0
- package/ACTION_CHECKLIST.md +342 -0
- package/APPCLEAN_SUMMARY.md +309 -0
- package/CHANGELOG.md +205 -0
- package/CODE_OF_CONDUCT.md +49 -0
- package/CODE_REVIEW_REPORT.md +447 -0
- package/COMMUNITY_POSTS.md +307 -0
- package/CONTRIBUTING.md +121 -0
- package/DEPLOYMENT_GUIDE.md +345 -0
- package/DEPLOYMENT_STATUS.md +182 -0
- package/EXECUTIVE_REPORT.md +393 -0
- package/GITHUB_OPTIMIZATION.md +383 -0
- package/INDEX.md +165 -0
- package/LICENSE +21 -0
- package/MARKETING_SUMMARY.md +352 -0
- package/NPM_PACKAGE_OPTIMIZATION.md +281 -0
- package/NPM_PUBLISH.md +116 -0
- package/PROJECT_SUMMARY.txt +249 -0
- package/QUICKSTART.md +219 -0
- package/README.md +548 -0
- package/SECURITY.md +104 -0
- package/SETUP_GITHUB.md +237 -0
- package/TESTING_SUMMARY.md +379 -0
- package/dist/core/appUpdateChecker.d.ts +23 -0
- package/dist/core/appUpdateChecker.d.ts.map +1 -0
- package/dist/core/appUpdateChecker.js +159 -0
- package/dist/core/appUpdateChecker.js.map +1 -0
- package/dist/core/detector.d.ts +13 -0
- package/dist/core/detector.d.ts.map +1 -0
- package/dist/core/detector.js +99 -0
- package/dist/core/detector.js.map +1 -0
- package/dist/core/duplicateFileFinder.d.ts +14 -0
- package/dist/core/duplicateFileFinder.d.ts.map +1 -0
- package/dist/core/duplicateFileFinder.js +80 -0
- package/dist/core/duplicateFileFinder.js.map +1 -0
- package/dist/core/orphanedDependencyDetector.d.ts +19 -0
- package/dist/core/orphanedDependencyDetector.d.ts.map +1 -0
- package/dist/core/orphanedDependencyDetector.js +148 -0
- package/dist/core/orphanedDependencyDetector.js.map +1 -0
- package/dist/core/performanceOptimizer.d.ts +37 -0
- package/dist/core/performanceOptimizer.d.ts.map +1 -0
- package/dist/core/performanceOptimizer.js +128 -0
- package/dist/core/performanceOptimizer.js.map +1 -0
- package/dist/core/permissionHandler.d.ts +9 -0
- package/dist/core/permissionHandler.d.ts.map +1 -0
- package/dist/core/permissionHandler.js +89 -0
- package/dist/core/permissionHandler.js.map +1 -0
- package/dist/core/pluginSystem.d.ts +39 -0
- package/dist/core/pluginSystem.d.ts.map +1 -0
- package/dist/core/pluginSystem.js +120 -0
- package/dist/core/pluginSystem.js.map +1 -0
- package/dist/core/removalRecorder.d.ts +32 -0
- package/dist/core/removalRecorder.d.ts.map +1 -0
- package/dist/core/removalRecorder.js +79 -0
- package/dist/core/removalRecorder.js.map +1 -0
- package/dist/core/remover.d.ts +15 -0
- package/dist/core/remover.d.ts.map +1 -0
- package/dist/core/remover.js +225 -0
- package/dist/core/remover.js.map +1 -0
- package/dist/core/reportGenerator.d.ts +9 -0
- package/dist/core/reportGenerator.d.ts.map +1 -0
- package/dist/core/reportGenerator.js +328 -0
- package/dist/core/reportGenerator.js.map +1 -0
- package/dist/core/scheduledCleanup.d.ts +38 -0
- package/dist/core/scheduledCleanup.d.ts.map +1 -0
- package/dist/core/scheduledCleanup.js +127 -0
- package/dist/core/scheduledCleanup.js.map +1 -0
- package/dist/core/serviceFileDetector.d.ts +18 -0
- package/dist/core/serviceFileDetector.d.ts.map +1 -0
- package/dist/core/serviceFileDetector.js +136 -0
- package/dist/core/serviceFileDetector.js.map +1 -0
- package/dist/core/verificationModule.d.ts +14 -0
- package/dist/core/verificationModule.d.ts.map +1 -0
- package/dist/core/verificationModule.js +102 -0
- package/dist/core/verificationModule.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +333 -0
- package/dist/index.js.map +1 -0
- package/dist/managers/brewManager.d.ts +10 -0
- package/dist/managers/brewManager.d.ts.map +1 -0
- package/dist/managers/brewManager.js +130 -0
- package/dist/managers/brewManager.js.map +1 -0
- package/dist/managers/customManager.d.ts +8 -0
- package/dist/managers/customManager.d.ts.map +1 -0
- package/dist/managers/customManager.js +139 -0
- package/dist/managers/customManager.js.map +1 -0
- package/dist/managers/linuxManager.d.ts +10 -0
- package/dist/managers/linuxManager.d.ts.map +1 -0
- package/dist/managers/linuxManager.js +191 -0
- package/dist/managers/linuxManager.js.map +1 -0
- package/dist/managers/npmManager.d.ts +10 -0
- package/dist/managers/npmManager.d.ts.map +1 -0
- package/dist/managers/npmManager.js +119 -0
- package/dist/managers/npmManager.js.map +1 -0
- package/dist/types/index.d.ts +44 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ui/guiServer.d.ts +10 -0
- package/dist/ui/guiServer.d.ts.map +1 -0
- package/dist/ui/guiServer.js +134 -0
- package/dist/ui/guiServer.js.map +1 -0
- package/dist/ui/menu.d.ts +6 -0
- package/dist/ui/menu.d.ts.map +1 -0
- package/dist/ui/menu.js +93 -0
- package/dist/ui/menu.js.map +1 -0
- package/dist/ui/prompts.d.ts +13 -0
- package/dist/ui/prompts.d.ts.map +1 -0
- package/dist/ui/prompts.js +161 -0
- package/dist/ui/prompts.js.map +1 -0
- package/dist/utils/filesystem.d.ts +13 -0
- package/dist/utils/filesystem.d.ts.map +1 -0
- package/dist/utils/filesystem.js +152 -0
- package/dist/utils/filesystem.js.map +1 -0
- package/dist/utils/logger.d.ts +12 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +49 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/platform.d.ts +9 -0
- package/dist/utils/platform.d.ts.map +1 -0
- package/dist/utils/platform.js +75 -0
- package/dist/utils/platform.js.map +1 -0
- package/jest.config.js +20 -0
- package/logo.svg +60 -0
- package/package.json +55 -0
- package/setup-github.sh +125 -0
- package/src/core/appUpdateChecker.ts +220 -0
- package/src/core/detector.ts +133 -0
- package/src/core/duplicateFileFinder.ts +113 -0
- package/src/core/orphanedDependencyDetector.ts +195 -0
- package/src/core/performanceOptimizer.ts +209 -0
- package/src/core/permissionHandler.ts +121 -0
- package/src/core/pluginSystem.ts +194 -0
- package/src/core/removalRecorder.ts +146 -0
- package/src/core/remover.ts +280 -0
- package/src/core/reportGenerator.ts +354 -0
- package/src/core/scheduledCleanup.ts +204 -0
- package/src/core/serviceFileDetector.ts +181 -0
- package/src/core/verificationModule.ts +140 -0
- package/src/index.ts +449 -0
- package/src/managers/brewManager.ts +149 -0
- package/src/managers/customManager.ts +167 -0
- package/src/managers/linuxManager.ts +210 -0
- package/src/managers/npmManager.ts +137 -0
- package/src/types/index.ts +59 -0
- package/src/ui/guiServer.ts +155 -0
- package/src/ui/menu.ts +100 -0
- package/src/ui/prompts.ts +177 -0
- package/src/utils/filesystem.ts +145 -0
- package/src/utils/logger.ts +48 -0
- package/src/utils/platform.ts +75 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { isMacOS, isLinux, getHomeDir } from '../utils/platform';
|
|
3
|
+
import { pathExists, readFile, listDirectory } from '../utils/filesystem';
|
|
4
|
+
import { ArtifactPath } from '../types';
|
|
5
|
+
|
|
6
|
+
export interface ServiceFile {
|
|
7
|
+
path: string;
|
|
8
|
+
type: 'launchagent' | 'launchdaemon' | 'systemd' | 'service';
|
|
9
|
+
appName: string;
|
|
10
|
+
requiresManualCleanup: boolean;
|
|
11
|
+
dependencies?: string[];
|
|
12
|
+
manualCleanupInstructions: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ServiceFileDetector {
|
|
16
|
+
/**
|
|
17
|
+
* Find all service files related to an app
|
|
18
|
+
*/
|
|
19
|
+
async findServiceFiles(appName: string): Promise<ServiceFile[]> {
|
|
20
|
+
const serviceFiles: ServiceFile[] = [];
|
|
21
|
+
|
|
22
|
+
if (isMacOS()) {
|
|
23
|
+
serviceFiles.push(...(await this.findMacOSServiceFiles(appName)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (isLinux()) {
|
|
27
|
+
serviceFiles.push(...(await this.findLinuxServiceFiles(appName)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return serviceFiles;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find macOS LaunchAgent and LaunchDaemon files
|
|
35
|
+
*/
|
|
36
|
+
private async findMacOSServiceFiles(appName: string): Promise<ServiceFile[]> {
|
|
37
|
+
const home = getHomeDir();
|
|
38
|
+
const serviceFiles: ServiceFile[] = [];
|
|
39
|
+
|
|
40
|
+
// LaunchAgents
|
|
41
|
+
const launchAgentsPath = path.join(home, 'Library', 'LaunchAgents');
|
|
42
|
+
if (pathExists(launchAgentsPath)) {
|
|
43
|
+
const agents = listDirectory(launchAgentsPath);
|
|
44
|
+
for (const agent of agents) {
|
|
45
|
+
if (agent.toLowerCase().includes(appName.toLowerCase())) {
|
|
46
|
+
const fullPath = path.join(launchAgentsPath, agent);
|
|
47
|
+
serviceFiles.push({
|
|
48
|
+
path: fullPath,
|
|
49
|
+
type: 'launchagent',
|
|
50
|
+
appName,
|
|
51
|
+
requiresManualCleanup: this.checkDependencies(fullPath, appName),
|
|
52
|
+
manualCleanupInstructions: `To manually remove: rm "${fullPath}"`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// LaunchDaemons
|
|
59
|
+
const launchDaemonsPath = path.join(home, 'Library', 'LaunchDaemons');
|
|
60
|
+
if (pathExists(launchDaemonsPath)) {
|
|
61
|
+
const daemons = listDirectory(launchDaemonsPath);
|
|
62
|
+
for (const daemon of daemons) {
|
|
63
|
+
if (daemon.toLowerCase().includes(appName.toLowerCase())) {
|
|
64
|
+
const fullPath = path.join(launchDaemonsPath, daemon);
|
|
65
|
+
serviceFiles.push({
|
|
66
|
+
path: fullPath,
|
|
67
|
+
type: 'launchdaemon',
|
|
68
|
+
appName,
|
|
69
|
+
requiresManualCleanup: true, // Daemons usually need manual removal
|
|
70
|
+
manualCleanupInstructions: `To manually remove: sudo rm "${fullPath}" && sudo launchctl unload "${fullPath}"`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return serviceFiles;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Find Linux systemd service files
|
|
81
|
+
*/
|
|
82
|
+
private async findLinuxServiceFiles(appName: string): Promise<ServiceFile[]> {
|
|
83
|
+
const home = getHomeDir();
|
|
84
|
+
const serviceFiles: ServiceFile[] = [];
|
|
85
|
+
|
|
86
|
+
// User systemd services
|
|
87
|
+
const userSystemdPath = path.join(home, '.config', 'systemd', 'user');
|
|
88
|
+
if (pathExists(userSystemdPath)) {
|
|
89
|
+
const services = listDirectory(userSystemdPath);
|
|
90
|
+
for (const service of services) {
|
|
91
|
+
if (service.includes(appName)) {
|
|
92
|
+
const fullPath = path.join(userSystemdPath, service);
|
|
93
|
+
serviceFiles.push({
|
|
94
|
+
path: fullPath,
|
|
95
|
+
type: 'systemd',
|
|
96
|
+
appName,
|
|
97
|
+
requiresManualCleanup: this.checkDependencies(fullPath, appName),
|
|
98
|
+
manualCleanupInstructions: `To manually remove: systemctl --user stop ${service} && rm "${fullPath}" && systemctl --user daemon-reload`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// System-wide services (requires elevation)
|
|
105
|
+
const systemServicePath = '/etc/systemd/system';
|
|
106
|
+
if (pathExists(systemServicePath)) {
|
|
107
|
+
try {
|
|
108
|
+
const services = listDirectory(systemServicePath);
|
|
109
|
+
for (const service of services) {
|
|
110
|
+
if (service.includes(appName)) {
|
|
111
|
+
const fullPath = path.join(systemServicePath, service);
|
|
112
|
+
serviceFiles.push({
|
|
113
|
+
path: fullPath,
|
|
114
|
+
type: 'service',
|
|
115
|
+
appName,
|
|
116
|
+
requiresManualCleanup: true,
|
|
117
|
+
manualCleanupInstructions: `To manually remove: sudo systemctl stop ${service} && sudo rm "${fullPath}" && sudo systemctl daemon-reload`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// Permission denied, skip system services
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return serviceFiles;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Check if service has dependencies
|
|
131
|
+
*/
|
|
132
|
+
private checkDependencies(filePath: string, appName: string): boolean {
|
|
133
|
+
try {
|
|
134
|
+
const content = readFile(filePath);
|
|
135
|
+
if (!content) return false;
|
|
136
|
+
|
|
137
|
+
// Check for KeepAlive, RunAtLoad, or other critical properties
|
|
138
|
+
return (
|
|
139
|
+
content.includes('KeepAlive') ||
|
|
140
|
+
content.includes('RunAtLoad') ||
|
|
141
|
+
content.includes('StartCalendarInterval')
|
|
142
|
+
);
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Convert service files to artifact paths for UI display
|
|
150
|
+
*/
|
|
151
|
+
convertToArtifacts(serviceFiles: ServiceFile[]): ArtifactPath[] {
|
|
152
|
+
return serviceFiles.map((file) => ({
|
|
153
|
+
path: file.path,
|
|
154
|
+
type: 'service',
|
|
155
|
+
size: 0,
|
|
156
|
+
description: `${file.type} service file (requires manual cleanup)`,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get manual cleanup instructions
|
|
162
|
+
*/
|
|
163
|
+
getManualCleanupInstructions(serviceFiles: ServiceFile[]): string {
|
|
164
|
+
if (serviceFiles.length === 0) {
|
|
165
|
+
return '';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let instructions = '\nā ļø Manual Cleanup Required:\n\n';
|
|
169
|
+
instructions += 'The following service files require manual cleanup:\n\n';
|
|
170
|
+
|
|
171
|
+
serviceFiles.forEach((file, index) => {
|
|
172
|
+
instructions += `${index + 1}. ${file.type.toUpperCase()}\n`;
|
|
173
|
+
instructions += ` Path: ${file.path}\n`;
|
|
174
|
+
instructions += ` Command: ${file.manualCleanupInstructions}\n\n`;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
instructions += 'Run these commands in your terminal to complete the cleanup.\n';
|
|
178
|
+
|
|
179
|
+
return instructions;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { isMacOS, isLinux, isWindows } from '../utils/platform';
|
|
3
|
+
import { pathExists } from '../utils/filesystem';
|
|
4
|
+
|
|
5
|
+
export type VerificationStatus = 'verified_removed' | 'still_exists' | 'partial_removal' | 'unknown';
|
|
6
|
+
|
|
7
|
+
export interface VerificationResult {
|
|
8
|
+
status: VerificationStatus;
|
|
9
|
+
remainingPaths: string[];
|
|
10
|
+
commandOutput: string;
|
|
11
|
+
timestamp: Date;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class VerificationModule {
|
|
15
|
+
/**
|
|
16
|
+
* Verify if app is completely removed from the system
|
|
17
|
+
*/
|
|
18
|
+
async verifyRemoval(
|
|
19
|
+
appName: string,
|
|
20
|
+
artifactPaths: string[]
|
|
21
|
+
): Promise<VerificationResult> {
|
|
22
|
+
const result: VerificationResult = {
|
|
23
|
+
status: 'unknown',
|
|
24
|
+
remainingPaths: [],
|
|
25
|
+
commandOutput: '',
|
|
26
|
+
timestamp: new Date(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
// Check if artifacts still exist on filesystem
|
|
31
|
+
const remainingPaths = artifactPaths.filter((path) => pathExists(path));
|
|
32
|
+
result.remainingPaths = remainingPaths;
|
|
33
|
+
|
|
34
|
+
// Verify via command line
|
|
35
|
+
const commandStatus = await this.verifyViaCommand(appName);
|
|
36
|
+
result.commandOutput = commandStatus;
|
|
37
|
+
|
|
38
|
+
// Determine overall status
|
|
39
|
+
if (remainingPaths.length === 0 && !commandStatus) {
|
|
40
|
+
result.status = 'verified_removed';
|
|
41
|
+
} else if (remainingPaths.length === 0 && commandStatus) {
|
|
42
|
+
result.status = 'unknown'; // Found via command but not filesystem
|
|
43
|
+
} else if (remainingPaths.length > 0 && remainingPaths.length < artifactPaths.length) {
|
|
44
|
+
result.status = 'partial_removal';
|
|
45
|
+
} else if (remainingPaths.length === artifactPaths.length) {
|
|
46
|
+
result.status = 'still_exists';
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
result.status = 'unknown';
|
|
50
|
+
result.commandOutput = (error as Error).message;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verify via command line (which, where, etc.)
|
|
58
|
+
*/
|
|
59
|
+
private async verifyViaCommand(appName: string): Promise<string> {
|
|
60
|
+
try {
|
|
61
|
+
if (isMacOS() || isLinux()) {
|
|
62
|
+
// Try 'which' command to locate binary
|
|
63
|
+
try {
|
|
64
|
+
const output = execSync(`which ${appName} 2>/dev/null || true`).toString().trim();
|
|
65
|
+
if (output) {
|
|
66
|
+
return `Found at: ${output}`;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// which command failed or app not found
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Try to run the app to see if it's accessible
|
|
73
|
+
try {
|
|
74
|
+
execSync(`${appName} --version 2>/dev/null || true`).toString().trim();
|
|
75
|
+
return `App still responds to --version`;
|
|
76
|
+
} catch {
|
|
77
|
+
// App not found or doesn't respond
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (isWindows()) {
|
|
84
|
+
// Try 'where' command on Windows
|
|
85
|
+
try {
|
|
86
|
+
const output = execSync(`where ${appName} 2>nul || echo ""`).toString().trim();
|
|
87
|
+
if (output && output !== '""') {
|
|
88
|
+
return `Found at: ${output}`;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// where command failed or app not found
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return '';
|
|
98
|
+
} catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Get human-readable status message
|
|
105
|
+
*/
|
|
106
|
+
getStatusMessage(status: VerificationStatus): string {
|
|
107
|
+
const messages: Record<VerificationStatus, string> = {
|
|
108
|
+
verified_removed: 'ā Application successfully removed',
|
|
109
|
+
still_exists: 'ā Application still exists on system',
|
|
110
|
+
partial_removal: 'ā Application partially removed (some files remain)',
|
|
111
|
+
unknown: '? Verification status unknown',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return messages[status] || messages['unknown'];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Get detailed verification report
|
|
119
|
+
*/
|
|
120
|
+
getDetailedReport(result: VerificationResult): string {
|
|
121
|
+
let report = `\nš Verification Report\n`;
|
|
122
|
+
report += `${'ā'.repeat(40)}\n`;
|
|
123
|
+
report += `Status: ${this.getStatusMessage(result.status)}\n`;
|
|
124
|
+
|
|
125
|
+
if (result.remainingPaths.length > 0) {
|
|
126
|
+
report += `\nRemaining Files/Folders:\n`;
|
|
127
|
+
result.remainingPaths.forEach((path) => {
|
|
128
|
+
report += ` - ${path}\n`;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (result.commandOutput) {
|
|
133
|
+
report += `\nCommand Output: ${result.commandOutput}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
report += `Verified at: ${result.timestamp.toISOString()}\n`;
|
|
137
|
+
|
|
138
|
+
return report;
|
|
139
|
+
}
|
|
140
|
+
}
|