z-clean 0.3.1 → 0.3.3
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/README.ko.md +25 -3
- package/README.md +24 -7
- package/README.zh.md +25 -3
- package/bin/zclean.js +36 -40
- package/package.json +5 -3
- package/scripts/postinstall.js +7 -0
- package/src/cli-brand.js +129 -0
- package/src/commands/init.js +50 -0
- package/src/commands/scheduler.js +27 -23
- package/src/config.js +24 -14
- package/src/detector/custom-patterns.js +79 -0
- package/src/detector/patterns.js +30 -2
- package/src/installer/launchd.js +64 -15
- package/src/installer/systemd.js +39 -20
- package/src/installer/taskscheduler.js +17 -6
- package/src/killer.js +11 -2
- package/src/process-diagnostic.js +17 -0
- package/src/reporter.js +10 -8
- package/src/scanner.js +6 -2
- package/src/storage-security.js +65 -0
- package/src/terminal-text.js +11 -0
- package/src/windows-processes.js +37 -12
package/src/config.js
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
+
const {
|
|
7
|
+
appendPrivateFile,
|
|
8
|
+
sanitizeHistoryEntry,
|
|
9
|
+
sanitizeHistoryFile,
|
|
10
|
+
secureDirectory,
|
|
11
|
+
secureFile,
|
|
12
|
+
writePrivateFile,
|
|
13
|
+
} = require('./storage-security');
|
|
6
14
|
|
|
7
15
|
const CONFIG_DIR_ENV = 'ZCLEAN_CONFIG_DIR';
|
|
8
16
|
|
|
@@ -32,6 +40,7 @@ const DEFAULT_CONFIG = {
|
|
|
32
40
|
logRetention: '30d',
|
|
33
41
|
maxKillBatch: 20,
|
|
34
42
|
customAiDirs: [],
|
|
43
|
+
customPatterns: [],
|
|
35
44
|
};
|
|
36
45
|
|
|
37
46
|
/**
|
|
@@ -68,10 +77,7 @@ function parseMemory(str) {
|
|
|
68
77
|
* Ensure the config directory exists.
|
|
69
78
|
*/
|
|
70
79
|
function ensureConfigDir() {
|
|
71
|
-
|
|
72
|
-
if (!fs.existsSync(configDir)) {
|
|
73
|
-
fs.mkdirSync(configDir, { recursive: true });
|
|
74
|
-
}
|
|
80
|
+
secureDirectory(getConfigDir());
|
|
75
81
|
}
|
|
76
82
|
|
|
77
83
|
/**
|
|
@@ -79,12 +85,16 @@ function ensureConfigDir() {
|
|
|
79
85
|
*/
|
|
80
86
|
function loadConfig() {
|
|
81
87
|
ensureConfigDir();
|
|
88
|
+
sanitizeHistoryFile(getLogFile());
|
|
82
89
|
const configFile = getConfigFile();
|
|
83
90
|
if (fs.existsSync(configFile)) {
|
|
91
|
+
secureFile(configFile);
|
|
84
92
|
try {
|
|
85
93
|
const raw = fs.readFileSync(configFile, 'utf-8');
|
|
86
94
|
const userConfig = JSON.parse(raw);
|
|
87
|
-
|
|
95
|
+
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
96
|
+
if (!(parseDuration(config.maxAge) > 0)) config.maxAge = DEFAULT_CONFIG.maxAge;
|
|
97
|
+
return config;
|
|
88
98
|
} catch {
|
|
89
99
|
// Corrupted config — use defaults
|
|
90
100
|
return { ...DEFAULT_CONFIG };
|
|
@@ -98,7 +108,7 @@ function loadConfig() {
|
|
|
98
108
|
*/
|
|
99
109
|
function saveConfig(config) {
|
|
100
110
|
ensureConfigDir();
|
|
101
|
-
|
|
111
|
+
writePrivateFile(getConfigFile(), JSON.stringify(config, null, 2) + '\n');
|
|
102
112
|
}
|
|
103
113
|
|
|
104
114
|
/**
|
|
@@ -107,8 +117,8 @@ function saveConfig(config) {
|
|
|
107
117
|
*/
|
|
108
118
|
function appendLog(entry) {
|
|
109
119
|
ensureConfigDir();
|
|
110
|
-
const
|
|
111
|
-
|
|
120
|
+
const sanitized = sanitizeHistoryEntry({ timestamp: new Date().toISOString(), ...entry });
|
|
121
|
+
appendPrivateFile(getLogFile(), JSON.stringify(sanitized) + '\n');
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
/**
|
|
@@ -122,7 +132,7 @@ function readLogs(limit = 50) {
|
|
|
122
132
|
return lines
|
|
123
133
|
.slice(-limit)
|
|
124
134
|
.map((line) => {
|
|
125
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
135
|
+
try { return sanitizeHistoryEntry(JSON.parse(line)); } catch { return null; }
|
|
126
136
|
})
|
|
127
137
|
.filter(Boolean);
|
|
128
138
|
} catch {
|
|
@@ -140,15 +150,15 @@ function pruneLogs(config) {
|
|
|
140
150
|
|
|
141
151
|
const cutoff = Date.now() - retentionMs;
|
|
142
152
|
const lines = fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean);
|
|
143
|
-
const kept = lines.
|
|
153
|
+
const kept = lines.flatMap((line) => {
|
|
144
154
|
try {
|
|
145
|
-
const entry = JSON.parse(line);
|
|
146
|
-
return new Date(entry.timestamp).getTime() >= cutoff;
|
|
155
|
+
const entry = sanitizeHistoryEntry(JSON.parse(line));
|
|
156
|
+
return new Date(entry.timestamp).getTime() >= cutoff ? [JSON.stringify(entry)] : [];
|
|
147
157
|
} catch {
|
|
148
|
-
return
|
|
158
|
+
return [];
|
|
149
159
|
}
|
|
150
160
|
});
|
|
151
|
-
|
|
161
|
+
writePrivateFile(logFile, kept.join('\n') + (kept.length ? '\n' : ''));
|
|
152
162
|
}
|
|
153
163
|
|
|
154
164
|
/**
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { parseDuration } = require('../config');
|
|
4
|
+
|
|
5
|
+
const CUSTOM_PATTERN_MIN = 3;
|
|
6
|
+
const CUSTOM_PATTERN_MAX = 80;
|
|
7
|
+
const SAFE_CUSTOM_MAX_AGE = '24h';
|
|
8
|
+
const GENERIC_CUSTOM_PATTERNS = new Set([
|
|
9
|
+
'bash', 'bun', 'bunx', 'cargo', 'chrome', 'chromium', 'cmd', 'corepack', 'cscript', 'deno', 'dotnet', 'esbuild', 'fish',
|
|
10
|
+
'go', 'java', 'javaw', 'node', 'nodejs', 'npm', 'npx', 'perl', 'php', 'pip', 'pip3', 'pipx', 'pnpm',
|
|
11
|
+
'powershell', 'powershell_ise', 'pwsh', 'py', 'pyw', 'python', 'python3', 'pythonw', 'ruby', 'server', 'sh', 'sleep', 'ts-node', 'tsx',
|
|
12
|
+
'uv', 'vite', 'worker', 'wscript', 'yarn', 'zsh',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
function getCustomPatternError(value) {
|
|
16
|
+
if (typeof value !== 'string') return '--pattern must be a literal between 3 and 80 characters.';
|
|
17
|
+
const literal = value.trim();
|
|
18
|
+
if (literal.length < CUSTOM_PATTERN_MIN || literal.length > CUSTOM_PATTERN_MAX) {
|
|
19
|
+
return '--pattern must be a literal between 3 and 80 characters.';
|
|
20
|
+
}
|
|
21
|
+
if (/[\p{Cc}\p{Cf}]/u.test(literal)) {
|
|
22
|
+
return '--pattern must contain printable characters only.';
|
|
23
|
+
}
|
|
24
|
+
const lower = literal.toLowerCase();
|
|
25
|
+
const genericFragment = [...GENERIC_CUSTOM_PATTERNS].some((name) => name.includes(lower));
|
|
26
|
+
const genericExecutable = lower.split(/[\s/\\'"`]+/).some(isGenericExecutableToken);
|
|
27
|
+
if (genericFragment || genericExecutable) {
|
|
28
|
+
return '--pattern must be specific; generic runtime names are not allowed.';
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isGenericExecutableToken(token) {
|
|
34
|
+
const basename = token
|
|
35
|
+
.replace(/^[^a-z0-9]+|[^a-z0-9.]+$/g, '')
|
|
36
|
+
.replace(/\.(?:exe|cmd|bat|com)$/i, '');
|
|
37
|
+
const executableName = basename
|
|
38
|
+
.replace(/\.(?:cjs|mjs|js)$/i, '')
|
|
39
|
+
.replace(/-cli$/i, '');
|
|
40
|
+
return GENERIC_CUSTOM_PATTERNS.has(executableName)
|
|
41
|
+
|| isVersionedGenericRuntime(executableName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isVersionedGenericRuntime(value) {
|
|
45
|
+
const match = value.match(/^([a-z][a-z_-]*?)(?:\d[\d.]*(?:[a-z][a-z0-9]*)?)$/);
|
|
46
|
+
return Boolean(match && GENERIC_CUSTOM_PATTERNS.has(match[1]));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeCustomPattern(value) {
|
|
50
|
+
if (getCustomPatternError(value)) return null;
|
|
51
|
+
return value.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getCustomPatterns(config) {
|
|
55
|
+
if (!Array.isArray(config?.customPatterns)) return [];
|
|
56
|
+
return [...new Set(config.customPatterns.map(normalizeCustomPattern).filter(Boolean))];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createCustomPattern(literal, config) {
|
|
60
|
+
const configuredAge = config?.maxAge;
|
|
61
|
+
const maxOrphanAge = parseDuration(configuredAge) > 0
|
|
62
|
+
? configuredAge
|
|
63
|
+
: SAFE_CUSTOM_MAX_AGE;
|
|
64
|
+
return {
|
|
65
|
+
name: `custom:${literal}`,
|
|
66
|
+
literal,
|
|
67
|
+
minAge: 0,
|
|
68
|
+
maxOrphanAge,
|
|
69
|
+
orphanOnly: true,
|
|
70
|
+
strictOrphan: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
createCustomPattern,
|
|
76
|
+
getCustomPatternError,
|
|
77
|
+
getCustomPatterns,
|
|
78
|
+
normalizeCustomPattern,
|
|
79
|
+
};
|
package/src/detector/patterns.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const {
|
|
4
|
+
createCustomPattern,
|
|
5
|
+
getCustomPatternError,
|
|
6
|
+
getCustomPatterns,
|
|
7
|
+
normalizeCustomPattern,
|
|
8
|
+
} = require('./custom-patterns');
|
|
9
|
+
|
|
3
10
|
/**
|
|
4
11
|
* Process patterns to detect AI tool zombies.
|
|
5
12
|
*
|
|
@@ -211,7 +218,7 @@ const PATTERNS = [
|
|
|
211
218
|
* @param {object} [config] — optional config with customAiDirs
|
|
212
219
|
* @returns {object|null} matched pattern or null
|
|
213
220
|
*/
|
|
214
|
-
function matchPattern(cmdline, config) {
|
|
221
|
+
function matchPattern(cmdline, config, customPatterns = getCustomPatterns(config)) {
|
|
215
222
|
const aiRegex = config && config.customAiDirs && config.customAiDirs.length > 0
|
|
216
223
|
? buildAiDirRegex(config.customAiDirs)
|
|
217
224
|
: AI_DIR_REGEX;
|
|
@@ -225,7 +232,28 @@ function matchPattern(cmdline, config) {
|
|
|
225
232
|
return pattern;
|
|
226
233
|
}
|
|
227
234
|
}
|
|
235
|
+
|
|
236
|
+
const command = String(cmdline).toLowerCase();
|
|
237
|
+
for (const literal of customPatterns) {
|
|
238
|
+
if (command.includes(literal.toLowerCase())) {
|
|
239
|
+
return createCustomPattern(literal, config);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
228
242
|
return null;
|
|
229
243
|
}
|
|
230
244
|
|
|
231
|
-
|
|
245
|
+
function createPatternMatcher(config) {
|
|
246
|
+
const customPatterns = getCustomPatterns(config);
|
|
247
|
+
return (cmdline) => matchPattern(cmdline, config, customPatterns);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
PATTERNS,
|
|
252
|
+
AI_TOOL_DIRS,
|
|
253
|
+
AI_DIR_REGEX,
|
|
254
|
+
buildAiDirRegex,
|
|
255
|
+
createPatternMatcher,
|
|
256
|
+
getCustomPatternError,
|
|
257
|
+
matchPattern,
|
|
258
|
+
normalizeCustomPattern,
|
|
259
|
+
};
|
package/src/installer/launchd.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
-
const { execSync } = require('child_process');
|
|
6
|
+
const { execFileSync, execSync } = require('child_process');
|
|
7
7
|
const { LOCAL_BIN_HINT, resolveZcleanBin } = require('./bin-path');
|
|
8
8
|
|
|
9
9
|
const PLIST_NAME = 'com.zclean.hourly';
|
|
@@ -136,12 +136,14 @@ function installLaunchd() {
|
|
|
136
136
|
} catch (err) {
|
|
137
137
|
return {
|
|
138
138
|
installed: true,
|
|
139
|
+
active: false,
|
|
139
140
|
message: `Plist written to ${PLIST_PATH} but launchctl load failed: ${err.message}. Try: launchctl bootstrap gui/$(id -u) ${PLIST_PATH}`,
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
143
|
|
|
143
144
|
return {
|
|
144
145
|
installed: true,
|
|
146
|
+
active: true,
|
|
145
147
|
message: `Hourly launchd agent installed: ${PLIST_PATH}`,
|
|
146
148
|
};
|
|
147
149
|
}
|
|
@@ -151,32 +153,79 @@ function installLaunchd() {
|
|
|
151
153
|
*
|
|
152
154
|
* @returns {{ removed: boolean, message: string }}
|
|
153
155
|
*/
|
|
154
|
-
function removeLaunchd() {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
function removeLaunchd(options = {}) {
|
|
157
|
+
const platform = options.platform || os.platform();
|
|
158
|
+
const plistPath = options.plistPath || PLIST_PATH;
|
|
159
|
+
const uid = options.uid ?? process.getuid?.();
|
|
160
|
+
const run = options.execFileSync || execFileSync;
|
|
161
|
+
|
|
162
|
+
if (platform !== 'darwin') {
|
|
163
|
+
return { removed: false, failed: false, message: 'launchd is macOS only.' };
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
const hasPlist = fs.existsSync(plistPath);
|
|
167
|
+
let unloaded = false;
|
|
168
|
+
let serviceRemoved = false;
|
|
169
|
+
let stateUnknown = false;
|
|
163
170
|
try {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
});
|
|
171
|
+
run('launchctl', ['bootout', `gui/${uid}/${PLIST_NAME}`], launchctlOptions());
|
|
172
|
+
unloaded = true;
|
|
173
|
+
serviceRemoved = true;
|
|
168
174
|
} catch {
|
|
169
|
-
|
|
175
|
+
if (hasPlist) {
|
|
176
|
+
try {
|
|
177
|
+
run('launchctl', ['bootout', `gui/${uid}`, plistPath], launchctlOptions());
|
|
178
|
+
unloaded = true;
|
|
179
|
+
serviceRemoved = true;
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!unloaded) {
|
|
185
|
+
try {
|
|
186
|
+
run('launchctl', ['print', `gui/${uid}/${PLIST_NAME}`], launchctlOptions());
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (isLaunchdServiceMissing(err)) {
|
|
189
|
+
unloaded = true;
|
|
190
|
+
} else {
|
|
191
|
+
stateUnknown = true;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!unloaded) {
|
|
197
|
+
return {
|
|
198
|
+
removed: false,
|
|
199
|
+
failed: true,
|
|
200
|
+
message: stateUnknown
|
|
201
|
+
? 'Could not verify launchd service removal; plist preserved when present.'
|
|
202
|
+
: 'launchd agent is still loaded; plist preserved for a later retry.',
|
|
203
|
+
};
|
|
170
204
|
}
|
|
171
205
|
|
|
172
|
-
fs.unlinkSync(
|
|
206
|
+
if (hasPlist) fs.unlinkSync(plistPath);
|
|
207
|
+
if (!hasPlist && !serviceRemoved) {
|
|
208
|
+
return { removed: false, failed: false, message: 'Plist and launchd service not found. Already uninstalled.' };
|
|
209
|
+
}
|
|
173
210
|
|
|
174
211
|
return {
|
|
175
212
|
removed: true,
|
|
176
|
-
|
|
213
|
+
failed: false,
|
|
214
|
+
message: hasPlist
|
|
215
|
+
? `launchd agent removed: ${plistPath}`
|
|
216
|
+
: 'launchd service removed; plist was already absent.',
|
|
177
217
|
};
|
|
178
218
|
}
|
|
179
219
|
|
|
220
|
+
function launchctlOptions() {
|
|
221
|
+
return { encoding: 'utf-8', timeout: 5000 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isLaunchdServiceMissing(err) {
|
|
225
|
+
const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
|
|
226
|
+
return /could not find service|service not found|no such process/i.test(output);
|
|
227
|
+
}
|
|
228
|
+
|
|
180
229
|
function escapeXml(value) {
|
|
181
230
|
return String(value)
|
|
182
231
|
.replace(/&/g, '&')
|
package/src/installer/systemd.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const os = require('os');
|
|
6
|
-
const { execSync } = require('child_process');
|
|
6
|
+
const { execFileSync, execSync } = require('child_process');
|
|
7
7
|
const { LOCAL_BIN_HINT, quoteSystemdArg, resolveZcleanBin } = require('./bin-path');
|
|
8
8
|
|
|
9
9
|
const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
@@ -72,6 +72,7 @@ function installSystemd() {
|
|
|
72
72
|
|
|
73
73
|
// Reload and enable
|
|
74
74
|
const messages = [];
|
|
75
|
+
let active = true;
|
|
75
76
|
try {
|
|
76
77
|
execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
|
|
77
78
|
execSync(`systemctl --user enable --now ${SERVICE_NAME}.timer`, {
|
|
@@ -80,6 +81,7 @@ function installSystemd() {
|
|
|
80
81
|
});
|
|
81
82
|
messages.push(`Timer installed and enabled: ${TIMER_PATH}`);
|
|
82
83
|
} catch (err) {
|
|
84
|
+
active = false;
|
|
83
85
|
messages.push(`Files written but systemctl failed: ${err.message}`);
|
|
84
86
|
messages.push(`Try manually: systemctl --user enable --now ${SERVICE_NAME}.timer`);
|
|
85
87
|
}
|
|
@@ -96,6 +98,7 @@ function installSystemd() {
|
|
|
96
98
|
|
|
97
99
|
return {
|
|
98
100
|
installed: true,
|
|
101
|
+
active,
|
|
99
102
|
message: messages.join('\n'),
|
|
100
103
|
};
|
|
101
104
|
}
|
|
@@ -105,41 +108,57 @@ function installSystemd() {
|
|
|
105
108
|
*
|
|
106
109
|
* @returns {{ removed: boolean, message: string }}
|
|
107
110
|
*/
|
|
108
|
-
function removeSystemd() {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
function removeSystemd(options = {}) {
|
|
112
|
+
const platform = options.platform || os.platform();
|
|
113
|
+
const servicePath = options.servicePath || SERVICE_PATH;
|
|
114
|
+
const timerPath = options.timerPath || TIMER_PATH;
|
|
115
|
+
const run = options.execFileSync || execFileSync;
|
|
116
|
+
if (platform !== 'linux') {
|
|
117
|
+
return { removed: false, failed: false, message: 'systemd is Linux only.' };
|
|
111
118
|
}
|
|
112
119
|
|
|
113
|
-
|
|
120
|
+
const hasFiles = fs.existsSync(servicePath) || fs.existsSync(timerPath);
|
|
114
121
|
try {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
run('systemctl', ['--user', 'disable', '--now', `${SERVICE_NAME}.timer`], systemctlOptions());
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (!hasFiles && isSystemdUnitMissing(err)) {
|
|
125
|
+
return { removed: false, failed: false, message: 'Files and timer not found. Already uninstalled.' };
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
removed: false,
|
|
129
|
+
failed: true,
|
|
130
|
+
message: 'Could not disable systemd timer; unit files preserved for a later retry.',
|
|
131
|
+
};
|
|
121
132
|
}
|
|
122
133
|
|
|
123
|
-
|
|
124
|
-
let removed = false;
|
|
125
|
-
for (const filepath of [SERVICE_PATH, TIMER_PATH]) {
|
|
134
|
+
for (const filepath of [servicePath, timerPath]) {
|
|
126
135
|
if (fs.existsSync(filepath)) {
|
|
127
136
|
fs.unlinkSync(filepath);
|
|
128
|
-
removed = true;
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
// Reload daemon
|
|
133
140
|
try {
|
|
134
|
-
|
|
135
|
-
} catch {
|
|
141
|
+
run('systemctl', ['--user', 'daemon-reload'], systemctlOptions());
|
|
142
|
+
} catch {
|
|
143
|
+
return { removed: true, failed: true, message: 'Timer disabled, but systemd daemon-reload failed.' };
|
|
144
|
+
}
|
|
136
145
|
|
|
137
146
|
return {
|
|
138
|
-
removed,
|
|
139
|
-
|
|
147
|
+
removed: true,
|
|
148
|
+
failed: false,
|
|
149
|
+
message: 'systemd timer and service removed.',
|
|
140
150
|
};
|
|
141
151
|
}
|
|
142
152
|
|
|
153
|
+
function systemctlOptions() {
|
|
154
|
+
return { encoding: 'utf-8', timeout: 5000 };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSystemdUnitMissing(err) {
|
|
158
|
+
const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
|
|
159
|
+
return /unit .* (?:does not exist|not found|could not be found)/i.test(output);
|
|
160
|
+
}
|
|
161
|
+
|
|
143
162
|
module.exports = {
|
|
144
163
|
installSystemd,
|
|
145
164
|
removeSystemd,
|
|
@@ -45,11 +45,13 @@ function installTaskScheduler() {
|
|
|
45
45
|
execFileSync('schtasks', args, { encoding: 'utf-8', timeout: 10000 });
|
|
46
46
|
return {
|
|
47
47
|
installed: true,
|
|
48
|
+
active: true,
|
|
48
49
|
message: `Task Scheduler task created: ${TASK_NAME} (hourly)`,
|
|
49
50
|
};
|
|
50
51
|
} catch (err) {
|
|
51
52
|
return {
|
|
52
53
|
installed: false,
|
|
54
|
+
active: false,
|
|
53
55
|
message: `Failed to create scheduled task: ${err.message}\nTry running as administrator.`,
|
|
54
56
|
};
|
|
55
57
|
}
|
|
@@ -60,31 +62,40 @@ function installTaskScheduler() {
|
|
|
60
62
|
*
|
|
61
63
|
* @returns {{ removed: boolean, message: string }}
|
|
62
64
|
*/
|
|
63
|
-
function removeTaskScheduler() {
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
function removeTaskScheduler(options = {}) {
|
|
66
|
+
const platform = options.platform || os.platform();
|
|
67
|
+
const run = options.execFileSync || execFileSync;
|
|
68
|
+
if (platform !== 'win32') {
|
|
69
|
+
return { removed: false, failed: false, message: 'Task Scheduler is Windows only.' };
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
try {
|
|
69
|
-
|
|
73
|
+
run('schtasks', ['/delete', '/TN', TASK_NAME, '/F'], {
|
|
70
74
|
encoding: 'utf-8',
|
|
71
75
|
timeout: 10000,
|
|
72
76
|
});
|
|
73
77
|
return {
|
|
74
78
|
removed: true,
|
|
79
|
+
failed: false,
|
|
75
80
|
message: `Task Scheduler task removed: ${TASK_NAME}`,
|
|
76
81
|
};
|
|
77
82
|
} catch (err) {
|
|
78
|
-
if (
|
|
79
|
-
return { removed: false, message: 'Task not found. Already uninstalled.' };
|
|
83
|
+
if (isTaskMissing(err)) {
|
|
84
|
+
return { removed: false, failed: false, message: 'Task not found. Already uninstalled.' };
|
|
80
85
|
}
|
|
81
86
|
return {
|
|
82
87
|
removed: false,
|
|
88
|
+
failed: true,
|
|
83
89
|
message: `Failed to remove task: ${err.message}`,
|
|
84
90
|
};
|
|
85
91
|
}
|
|
86
92
|
}
|
|
87
93
|
|
|
94
|
+
function isTaskMissing(err) {
|
|
95
|
+
const output = `${err?.message || ''}\n${String(err?.stdout || '')}\n${String(err?.stderr || '')}`;
|
|
96
|
+
return /task name(?: .*?)? does not exist|cannot find the (?:file|task)|task .* not found/i.test(output);
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
module.exports = {
|
|
89
100
|
installTaskScheduler,
|
|
90
101
|
removeTaskScheduler,
|
package/src/killer.js
CHANGED
|
@@ -56,7 +56,6 @@ function killZombies(zombies, config) {
|
|
|
56
56
|
action: 'kill',
|
|
57
57
|
pid: proc.pid,
|
|
58
58
|
name: proc.name,
|
|
59
|
-
cmd: proc.cmd,
|
|
60
59
|
reason: proc.reason,
|
|
61
60
|
memFreed: proc.mem,
|
|
62
61
|
});
|
|
@@ -69,7 +68,6 @@ function killZombies(zombies, config) {
|
|
|
69
68
|
action: 'kill-failed',
|
|
70
69
|
pid: proc.pid,
|
|
71
70
|
name: proc.name,
|
|
72
|
-
cmd: proc.cmd,
|
|
73
71
|
error: killResult.error,
|
|
74
72
|
});
|
|
75
73
|
}
|
|
@@ -120,6 +118,9 @@ function verifyProcessUnix(proc, runtime = runtimeOptions()) {
|
|
|
120
118
|
if (scanPrefix !== currentPrefix) {
|
|
121
119
|
return { valid: false, reason: 'cmd-mismatch' };
|
|
122
120
|
}
|
|
121
|
+
if (!matchesCustomLiteral(proc, cmd)) {
|
|
122
|
+
return { valid: false, reason: 'pattern-mismatch' };
|
|
123
|
+
}
|
|
123
124
|
|
|
124
125
|
// Verify start time if available
|
|
125
126
|
if (proc.startTime) {
|
|
@@ -156,6 +157,9 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
|
|
|
156
157
|
if (scanPrefix !== currentPrefix) {
|
|
157
158
|
return { valid: false, reason: 'cmd-mismatch' };
|
|
158
159
|
}
|
|
160
|
+
if (!matchesCustomLiteral(proc, currentCmd)) {
|
|
161
|
+
return { valid: false, reason: 'pattern-mismatch' };
|
|
162
|
+
}
|
|
159
163
|
|
|
160
164
|
if (proc.startTime && current.startTime && current.startTime !== proc.startTime) {
|
|
161
165
|
return { valid: false, reason: 'start-time-mismatch' };
|
|
@@ -168,6 +172,11 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
|
|
|
168
172
|
return { valid: true, reason: 'verified' };
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
function matchesCustomLiteral(proc, currentCmd) {
|
|
176
|
+
if (!proc.matchLiteral) return true;
|
|
177
|
+
return String(currentCmd).toLowerCase().includes(String(proc.matchLiteral).toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
|
|
171
180
|
/**
|
|
172
181
|
* Kill a process with graceful shutdown sequence.
|
|
173
182
|
*
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function providerDiagnostic(provider, code, error) {
|
|
4
|
+
return {
|
|
5
|
+
code,
|
|
6
|
+
provider,
|
|
7
|
+
message: diagnosticMessage(error),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function diagnosticMessage(error) {
|
|
12
|
+
const stderr = error && error.stderr ? String(error.stderr).trim() : '';
|
|
13
|
+
const fallback = error instanceof Error ? error.message : String(error || '');
|
|
14
|
+
return (stderr || fallback).replace(/\s+/g, ' ').slice(0, 500);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { providerDiagnostic };
|
package/src/reporter.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { sanitizeTerminalText } = require('./terminal-text');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* ANSI color codes — no external dependencies.
|
|
5
7
|
*/
|
|
@@ -78,7 +80,7 @@ function formatDiagnostic(item) {
|
|
|
78
80
|
const providers = Array.isArray(item.providers) && item.providers.length > 0
|
|
79
81
|
? ` (providers: ${item.providers.join(', ')})`
|
|
80
82
|
: '';
|
|
81
|
-
return `${code}${item.message || 'process scan diagnostic'}${providers}
|
|
83
|
+
return sanitizeTerminalText(`${code}${item.message || 'process scan diagnostic'}${providers}`);
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function reportScanDiagnostics(result) {
|
|
@@ -123,8 +125,8 @@ function reportDryRun(zombies) {
|
|
|
123
125
|
const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
|
|
124
126
|
|
|
125
127
|
for (const z of zombies) {
|
|
126
|
-
console.log(` ${c('red', 'PID')} ${c('bold', String(z.pid).padStart(6))} ${c('cyan', z.name.padEnd(16))} ${c('yellow', formatBytes(z.mem).padStart(8))} ${c('gray', formatDuration(z.age).padStart(6))}`);
|
|
127
|
-
console.log(` ${c('gray', ' cmd:')} ${truncate(z.cmd, 72)}`);
|
|
128
|
+
console.log(` ${c('red', 'PID')} ${c('bold', String(z.pid).padStart(6))} ${c('cyan', truncate(z.name, 16).padEnd(16))} ${c('yellow', formatBytes(z.mem).padStart(8))} ${c('gray', formatDuration(z.age).padStart(6))}`);
|
|
129
|
+
console.log(` ${c('gray', ' cmd:')} ${truncate(sanitizeTerminalText(z.cmd), 72)}`);
|
|
128
130
|
console.log(` ${c('gray', ' why:')} ${z.reason}`);
|
|
129
131
|
console.log();
|
|
130
132
|
}
|
|
@@ -155,7 +157,7 @@ function reportKill(results) {
|
|
|
155
157
|
const totalMem = killed.reduce((sum, p) => sum + p.mem, 0);
|
|
156
158
|
console.log(c('green', ` Killed ${killed.length} zombie process${killed.length === 1 ? '' : 'es'}:`));
|
|
157
159
|
for (const p of killed) {
|
|
158
|
-
console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
|
|
160
|
+
console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
|
|
159
161
|
}
|
|
160
162
|
console.log(c('green', `\n Memory freed: ${formatBytes(totalMem)}`));
|
|
161
163
|
|
|
@@ -170,14 +172,14 @@ function reportKill(results) {
|
|
|
170
172
|
if (skipped.length > 0) {
|
|
171
173
|
console.log(c('yellow', `\n Skipped ${skipped.length} (re-verification failed):`));
|
|
172
174
|
for (const p of skipped) {
|
|
173
|
-
console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} reason: ${p.skipReason}`);
|
|
175
|
+
console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} reason: ${sanitizeTerminalText(p.skipReason)}`);
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
if (failed.length > 0) {
|
|
178
180
|
console.log(c('red', `\n Failed to kill ${failed.length}:`));
|
|
179
181
|
for (const p of failed) {
|
|
180
|
-
console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} error: ${p.error}`);
|
|
182
|
+
console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${truncate(p.name, 16).padEnd(16)} error: ${sanitizeTerminalText(p.error)}`);
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
|
|
@@ -233,7 +235,7 @@ function reportLogs(logs) {
|
|
|
233
235
|
console.log(` ${time} ${c('green', 'KILL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${formatBytes(entry.memFreed || 0)}`);
|
|
234
236
|
break;
|
|
235
237
|
case 'kill-failed':
|
|
236
|
-
console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${entry.error || ''}`);
|
|
238
|
+
console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${sanitizeTerminalText(entry.error || '')}`);
|
|
237
239
|
break;
|
|
238
240
|
case 'cleanup-summary':
|
|
239
241
|
console.log(` ${time} ${c('cyan', 'DONE')} killed:${entry.killed} failed:${entry.failed} skipped:${entry.skipped} freed:${formatBytes(entry.totalMemFreed || 0)}`);
|
|
@@ -258,7 +260,7 @@ function reportConfig(config, configPath) {
|
|
|
258
260
|
const displayValue = Array.isArray(value)
|
|
259
261
|
? (value.length === 0 ? '[]' : JSON.stringify(value))
|
|
260
262
|
: String(value);
|
|
261
|
-
console.log(` ${c('cyan', key.padEnd(20))} ${displayValue}`);
|
|
263
|
+
console.log(` ${c('cyan', key.padEnd(20))} ${sanitizeTerminalText(displayValue)}`);
|
|
262
264
|
}
|
|
263
265
|
|
|
264
266
|
console.log();
|