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/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
- const configDir = getConfigDir();
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
- return { ...DEFAULT_CONFIG, ...userConfig };
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
- fs.writeFileSync(getConfigFile(), JSON.stringify(config, null, 2) + '\n', 'utf-8');
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 line = JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + '\n';
111
- fs.appendFileSync(getLogFile(), line, 'utf-8');
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.filter((line) => {
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 false;
158
+ return [];
149
159
  }
150
160
  });
151
- fs.writeFileSync(logFile, kept.join('\n') + (kept.length ? '\n' : ''), 'utf-8');
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
+ };
@@ -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
- module.exports = { PATTERNS, AI_TOOL_DIRS, AI_DIR_REGEX, buildAiDirRegex, matchPattern };
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
+ };
@@ -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
- if (os.platform() !== 'darwin') {
156
- return { removed: false, message: 'launchd is macOS only.' };
157
- }
158
-
159
- if (!fs.existsSync(PLIST_PATH)) {
160
- return { removed: false, message: 'Plist not found. Already uninstalled.' };
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
- execSync(`launchctl bootout gui/${process.getuid()} ${PLIST_PATH}`, {
165
- encoding: 'utf-8',
166
- timeout: 5000,
167
- });
171
+ run('launchctl', ['bootout', `gui/${uid}/${PLIST_NAME}`], launchctlOptions());
172
+ unloaded = true;
173
+ serviceRemoved = true;
168
174
  } catch {
169
- // Might not be loaded
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(PLIST_PATH);
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
- message: `launchd agent removed: ${PLIST_PATH}`,
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, '&amp;')
@@ -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
- if (os.platform() !== 'linux') {
110
- return { removed: false, message: 'systemd is Linux only.' };
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
- // Disable timer
120
+ const hasFiles = fs.existsSync(servicePath) || fs.existsSync(timerPath);
114
121
  try {
115
- execSync(`systemctl --user disable --now ${SERVICE_NAME}.timer`, {
116
- encoding: 'utf-8',
117
- timeout: 5000,
118
- });
119
- } catch {
120
- // Might not be running
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
- // Remove files
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
- execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
135
- } catch { /* ignore */ }
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
- message: removed ? 'systemd timer and service removed.' : 'Files not found. Already uninstalled.',
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
- if (os.platform() !== 'win32') {
65
- return { removed: false, message: 'Task Scheduler is Windows only.' };
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
- execFileSync('schtasks', ['/delete', '/TN', TASK_NAME, '/F'], {
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 (err.message.includes('does not exist') || err.message.includes('ERROR')) {
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();