z-clean 0.1.1 → 0.3.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.
@@ -4,48 +4,58 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const { execSync } = require('child_process');
7
+ const { LOCAL_BIN_HINT, resolveZcleanBin } = require('./bin-path');
7
8
 
8
9
  const PLIST_NAME = 'com.zclean.hourly';
9
10
  const PLIST_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
10
11
  const PLIST_PATH = path.join(PLIST_DIR, `${PLIST_NAME}.plist`);
11
12
 
12
13
  /**
13
- * Resolve the full path to the zclean binary.
14
- * Tries: npx global, npm global, local install.
14
+ * Resolve the active nvm node bin path, if nvm is installed.
15
+ * Returns the path or null.
15
16
  */
16
- function resolveZcleanBin() {
17
- // If installed globally via npm
17
+ function resolveNvmNodeBin() {
18
+ const nvmDir = path.join(os.homedir(), '.nvm', 'versions', 'node');
18
19
  try {
19
- const npmBin = execSync('npm bin -g', { encoding: 'utf-8', timeout: 5000 }).trim();
20
- const globalPath = path.join(npmBin, 'zclean');
21
- if (fs.existsSync(globalPath)) return globalPath;
22
- } catch { /* ignore */ }
23
-
24
- // Check common locations
25
- const candidates = [
26
- path.join(os.homedir(), '.local', 'bin', 'zclean'),
27
- '/usr/local/bin/zclean',
28
- path.join(os.homedir(), 'node_modules', '.bin', 'zclean'),
29
- ];
30
-
31
- for (const candidate of candidates) {
32
- if (fs.existsSync(candidate)) return candidate;
20
+ if (!fs.existsSync(nvmDir)) return null;
21
+ // Use the currently running node's path if it's under .nvm
22
+ const nodeBin = process.execPath;
23
+ if (nodeBin.includes('.nvm')) {
24
+ return path.dirname(nodeBin);
25
+ }
26
+ // Fallback: find the default version directory
27
+ const versions = fs.readdirSync(nvmDir).filter((d) => d.startsWith('v')).sort().reverse();
28
+ if (versions.length > 0) {
29
+ return path.join(nvmDir, versions[0], 'bin');
30
+ }
31
+ } catch {
32
+ // ignore
33
33
  }
34
-
35
- // Fallback to npx
36
- return 'npx zclean';
34
+ return null;
37
35
  }
38
36
 
39
37
  /**
40
38
  * Generate the launchd plist XML.
41
39
  */
42
40
  function generatePlist(binPath) {
43
- const parts = binPath.split(' ');
44
- const programArgs = parts
45
- .concat(['--yes'])
46
- .map((arg) => ` <string>${arg}</string>`)
41
+ const programArgs = [binPath, '--yes']
42
+ .map((arg) => ` <string>${escapeXml(arg)}</string>`)
47
43
  .join('\n');
48
44
 
45
+ // Build PATH with nvm node bin if available
46
+ const pathParts = [
47
+ '/usr/local/bin',
48
+ '/usr/bin',
49
+ '/bin',
50
+ '/opt/homebrew/bin',
51
+ path.join(os.homedir(), '.local', 'bin'),
52
+ ];
53
+ const nvmBin = resolveNvmNodeBin();
54
+ if (nvmBin) {
55
+ pathParts.push(nvmBin);
56
+ }
57
+ const envPath = pathParts.join(':');
58
+
49
59
  return `<?xml version="1.0" encoding="UTF-8"?>
50
60
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
51
61
  <plist version="1.0">
@@ -73,7 +83,7 @@ ${programArgs}
73
83
  <key>EnvironmentVariables</key>
74
84
  <dict>
75
85
  <key>PATH</key>
76
- <string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${path.join(os.homedir(), '.local', 'bin')}</string>
86
+ <string>${envPath}</string>
77
87
  </dict>
78
88
  </dict>
79
89
  </plist>
@@ -90,12 +100,16 @@ function installLaunchd() {
90
100
  return { installed: false, message: 'launchd is macOS only.' };
91
101
  }
92
102
 
103
+ const binPath = resolveZcleanBin();
104
+ if (!binPath) {
105
+ return { installed: false, message: `Local zclean executable not found. ${LOCAL_BIN_HINT}` };
106
+ }
107
+
93
108
  // Ensure LaunchAgents directory exists
94
109
  if (!fs.existsSync(PLIST_DIR)) {
95
110
  fs.mkdirSync(PLIST_DIR, { recursive: true });
96
111
  }
97
112
 
98
- const binPath = resolveZcleanBin();
99
113
  const plist = generatePlist(binPath);
100
114
 
101
115
  // Unload existing if present
@@ -163,4 +177,19 @@ function removeLaunchd() {
163
177
  };
164
178
  }
165
179
 
166
- module.exports = { installLaunchd, removeLaunchd, PLIST_PATH };
180
+ function escapeXml(value) {
181
+ return String(value)
182
+ .replace(/&/g, '&amp;')
183
+ .replace(/</g, '&lt;')
184
+ .replace(/>/g, '&gt;')
185
+ .replace(/"/g, '&quot;')
186
+ .replace(/'/g, '&apos;');
187
+ }
188
+
189
+ module.exports = {
190
+ installLaunchd,
191
+ removeLaunchd,
192
+ generatePlist,
193
+ resolveZcleanBin,
194
+ PLIST_PATH,
195
+ };
@@ -4,46 +4,24 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
  const { execSync } = require('child_process');
7
+ const { LOCAL_BIN_HINT, quoteSystemdArg, resolveZcleanBin } = require('./bin-path');
7
8
 
8
9
  const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
9
10
  const SERVICE_NAME = 'zclean';
10
11
  const SERVICE_PATH = path.join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.service`);
11
12
  const TIMER_PATH = path.join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.timer`);
12
13
 
13
- /**
14
- * Resolve the full path to the zclean binary.
15
- */
16
- function resolveZcleanBin() {
17
- try {
18
- const npmBin = execSync('npm bin -g', { encoding: 'utf-8', timeout: 5000 }).trim();
19
- const globalPath = path.join(npmBin, 'zclean');
20
- if (fs.existsSync(globalPath)) return globalPath;
21
- } catch { /* ignore */ }
22
-
23
- const candidates = [
24
- path.join(os.homedir(), '.local', 'bin', 'zclean'),
25
- '/usr/local/bin/zclean',
26
- path.join(os.homedir(), '.local', 'share', 'npm', 'bin', 'zclean'),
27
- ];
28
-
29
- for (const candidate of candidates) {
30
- if (fs.existsSync(candidate)) return candidate;
31
- }
32
-
33
- return path.join(os.homedir(), '.local', 'bin', 'zclean');
34
- }
35
-
36
14
  /**
37
15
  * Generate the systemd service unit file.
38
16
  */
39
17
  function generateService(binPath) {
40
18
  return `[Unit]
41
19
  Description=zclean - AI coding tool zombie process cleaner
42
- Documentation=https://github.com/whynowlab/zclean
20
+ Documentation=https://github.com/TheStack-ai/zclean
43
21
 
44
22
  [Service]
45
23
  Type=oneshot
46
- ExecStart=${binPath} --yes
24
+ ExecStart=${quoteSystemdArg(binPath)} --yes
47
25
  Environment=PATH=/usr/local/bin:/usr/bin:/bin:%h/.local/bin
48
26
  StandardOutput=append:%h/.zclean/systemd.log
49
27
  StandardError=append:%h/.zclean/systemd.log
@@ -56,7 +34,7 @@ StandardError=append:%h/.zclean/systemd.log
56
34
  function generateTimer() {
57
35
  return `[Unit]
58
36
  Description=Run zclean hourly
59
- Documentation=https://github.com/whynowlab/zclean
37
+ Documentation=https://github.com/TheStack-ai/zclean
60
38
 
61
39
  [Timer]
62
40
  OnCalendar=hourly
@@ -78,13 +56,16 @@ function installSystemd() {
78
56
  return { installed: false, message: 'systemd is Linux only.' };
79
57
  }
80
58
 
59
+ const binPath = resolveZcleanBin();
60
+ if (!binPath) {
61
+ return { installed: false, message: `Local zclean executable not found. ${LOCAL_BIN_HINT}` };
62
+ }
63
+
81
64
  // Ensure systemd user directory exists
82
65
  if (!fs.existsSync(SYSTEMD_USER_DIR)) {
83
66
  fs.mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
84
67
  }
85
68
 
86
- const binPath = resolveZcleanBin();
87
-
88
69
  // Write service and timer files
89
70
  fs.writeFileSync(SERVICE_PATH, generateService(binPath), 'utf-8');
90
71
  fs.writeFileSync(TIMER_PATH, generateTimer(), 'utf-8');
@@ -159,4 +140,12 @@ function removeSystemd() {
159
140
  };
160
141
  }
161
142
 
162
- module.exports = { installSystemd, removeSystemd, SERVICE_PATH, TIMER_PATH };
143
+ module.exports = {
144
+ installSystemd,
145
+ removeSystemd,
146
+ generateService,
147
+ generateTimer,
148
+ resolveZcleanBin,
149
+ SERVICE_PATH,
150
+ TIMER_PATH,
151
+ };
@@ -1,31 +1,25 @@
1
1
  'use strict';
2
2
 
3
- const { execSync } = require('child_process');
4
- const path = require('path');
3
+ const { execFileSync } = require('child_process');
5
4
  const os = require('os');
6
- const fs = require('fs');
5
+ const { LOCAL_BIN_HINT, resolveZcleanBin } = require('./bin-path');
7
6
 
8
7
  const TASK_NAME = 'zclean-hourly';
9
8
 
10
- /**
11
- * Resolve the full path to the zclean binary on Windows.
12
- */
13
- function resolveZcleanBin() {
14
- // Check npm global
15
- try {
16
- const npmPrefix = execSync('npm prefix -g', { encoding: 'utf-8', timeout: 5000 }).trim();
17
- const candidate = path.join(npmPrefix, 'zclean.cmd');
18
- if (fs.existsSync(candidate)) return candidate;
19
- const candidate2 = path.join(npmPrefix, 'node_modules', '.bin', 'zclean.cmd');
20
- if (fs.existsSync(candidate2)) return candidate2;
21
- } catch { /* ignore */ }
22
-
23
- // Check AppData local
24
- const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
25
- const npmGlobal = path.join(appData, 'npm', 'zclean.cmd');
26
- if (fs.existsSync(npmGlobal)) return npmGlobal;
9
+ function buildCreateTaskArgs(binPath) {
10
+ return [
11
+ '/create',
12
+ '/TN', TASK_NAME,
13
+ '/SC', 'HOURLY',
14
+ '/TR', formatTaskRunCommand(binPath),
15
+ '/F',
16
+ ];
17
+ }
27
18
 
28
- return 'npx zclean';
19
+ function formatTaskRunCommand(binPath) {
20
+ const text = String(binPath);
21
+ const command = /\s/.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
22
+ return `${command} --yes`;
29
23
  }
30
24
 
31
25
  /**
@@ -41,22 +35,14 @@ function installTaskScheduler() {
41
35
  }
42
36
 
43
37
  const binPath = resolveZcleanBin();
38
+ if (!binPath) {
39
+ return { installed: false, message: `Local zclean executable not found. ${LOCAL_BIN_HINT}` };
40
+ }
44
41
 
45
- // Build schtasks command
46
- // /SC HOURLY — run every hour
47
- // /TN — task name
48
- // /TR — task to run
49
- // /F — force overwrite if exists
50
- const command = [
51
- 'schtasks', '/create',
52
- '/TN', `"${TASK_NAME}"`,
53
- '/SC', 'HOURLY',
54
- '/TR', `"${binPath} --yes"`,
55
- '/F',
56
- ].join(' ');
42
+ const args = buildCreateTaskArgs(binPath);
57
43
 
58
44
  try {
59
- execSync(command, { encoding: 'utf-8', timeout: 10000 });
45
+ execFileSync('schtasks', args, { encoding: 'utf-8', timeout: 10000 });
60
46
  return {
61
47
  installed: true,
62
48
  message: `Task Scheduler task created: ${TASK_NAME} (hourly)`,
@@ -80,7 +66,7 @@ function removeTaskScheduler() {
80
66
  }
81
67
 
82
68
  try {
83
- execSync(`schtasks /delete /TN "${TASK_NAME}" /F`, {
69
+ execFileSync('schtasks', ['/delete', '/TN', TASK_NAME, '/F'], {
84
70
  encoding: 'utf-8',
85
71
  timeout: 10000,
86
72
  });
@@ -99,4 +85,11 @@ function removeTaskScheduler() {
99
85
  }
100
86
  }
101
87
 
102
- module.exports = { installTaskScheduler, removeTaskScheduler, TASK_NAME };
88
+ module.exports = {
89
+ installTaskScheduler,
90
+ removeTaskScheduler,
91
+ resolveZcleanBin,
92
+ buildCreateTaskArgs,
93
+ formatTaskRunCommand,
94
+ TASK_NAME,
95
+ };
package/src/killer.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { execSync } = require('child_process');
4
4
  const os = require('os');
5
5
  const { appendLog } = require('./config');
6
+ const { normalizePid, readWindowsProcess, windowsProcessExists } = require('./windows-processes');
6
7
 
7
8
  const platform = os.platform();
8
9
 
@@ -24,9 +25,17 @@ const platform = os.platform();
24
25
  */
25
26
  function killZombies(zombies, config) {
26
27
  const timeout = (config.sigterm_timeout || 10) * 1000;
27
- const results = { killed: [], failed: [], skipped: [] };
28
+ const limit = config.maxKillBatch || 20;
29
+ const results = { killed: [], failed: [], skipped: [], warning: null };
28
30
 
29
- for (const proc of zombies) {
31
+ // Rate limit: only kill up to maxKillBatch processes
32
+ let toKill = zombies;
33
+ if (zombies.length > limit) {
34
+ toKill = zombies.slice(0, limit);
35
+ results.warning = `Found ${zombies.length} zombies, killing ${limit}. Run again for remaining.`;
36
+ }
37
+
38
+ for (const proc of toKill) {
30
39
  // Re-verify before killing
31
40
  const verification = verifyProcess(proc);
32
41
  if (!verification.valid) {
@@ -73,6 +82,7 @@ function killZombies(zombies, config) {
73
82
  killed: results.killed.length,
74
83
  failed: results.failed.length,
75
84
  skipped: results.skipped.length,
85
+ limited: zombies.length > limit,
76
86
  totalMemFreed: results.killed.reduce((sum, p) => sum + p.mem, 0),
77
87
  });
78
88
 
@@ -83,17 +93,18 @@ function killZombies(zombies, config) {
83
93
  * Re-verify a process before killing it.
84
94
  * Ensures we don't kill a recycled PID or wrong process.
85
95
  */
86
- function verifyProcess(proc) {
87
- if (platform === 'win32') {
88
- return verifyProcessWindows(proc);
96
+ function verifyProcess(proc, options = {}) {
97
+ const runtime = runtimeOptions(options);
98
+ if (runtime.platform === 'win32') {
99
+ return verifyProcessWindows(proc, runtime);
89
100
  }
90
- return verifyProcessUnix(proc);
101
+ return verifyProcessUnix(proc, runtime);
91
102
  }
92
103
 
93
- function verifyProcessUnix(proc) {
104
+ function verifyProcessUnix(proc, runtime = runtimeOptions()) {
94
105
  try {
95
106
  // Check if PID still exists and get its command line
96
- const cmd = execSync(`ps -o command= -p ${proc.pid}`, {
107
+ const cmd = runtime.execSync(`ps -o command= -p ${proc.pid}`, {
97
108
  encoding: 'utf-8',
98
109
  timeout: 5000,
99
110
  }).trim();
@@ -113,7 +124,7 @@ function verifyProcessUnix(proc) {
113
124
  // Verify start time if available
114
125
  if (proc.startTime) {
115
126
  try {
116
- const lstart = execSync(`ps -o lstart= -p ${proc.pid}`, {
127
+ const lstart = runtime.execSync(`ps -o lstart= -p ${proc.pid}`, {
117
128
  encoding: 'utf-8',
118
129
  timeout: 5000,
119
130
  }).trim();
@@ -132,30 +143,29 @@ function verifyProcessUnix(proc) {
132
143
  }
133
144
  }
134
145
 
135
- function verifyProcessWindows(proc) {
136
- try {
137
- const output = execSync(
138
- `wmic process where ProcessId=${proc.pid} get CommandLine /value`,
139
- { encoding: 'utf-8', timeout: 5000 }
140
- ).trim();
146
+ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32' })) {
147
+ const current = readWindowsProcess(proc.pid, runtime);
148
+ if (!current) {
149
+ return { valid: false, reason: 'process-gone' };
150
+ }
141
151
 
142
- const match = output.match(/CommandLine=(.+)/);
143
- if (!match) {
144
- return { valid: false, reason: 'process-gone' };
145
- }
152
+ const currentCmd = String(current.cmd || '').trim();
153
+ const scanPrefix = proc.cmd.substring(0, 50);
154
+ const currentPrefix = currentCmd.substring(0, 50);
146
155
 
147
- const currentCmd = match[1].trim();
148
- const scanPrefix = proc.cmd.substring(0, 50);
149
- const currentPrefix = currentCmd.substring(0, 50);
156
+ if (scanPrefix !== currentPrefix) {
157
+ return { valid: false, reason: 'cmd-mismatch' };
158
+ }
150
159
 
151
- if (scanPrefix !== currentPrefix) {
152
- return { valid: false, reason: 'cmd-mismatch' };
153
- }
160
+ if (proc.startTime && current.startTime && current.startTime !== proc.startTime) {
161
+ return { valid: false, reason: 'start-time-mismatch' };
162
+ }
154
163
 
155
- return { valid: true, reason: 'verified' };
156
- } catch {
157
- return { valid: false, reason: 'process-gone' };
164
+ if (proc.startTime && !current.startTime) {
165
+ return { valid: false, reason: 'start-time-unverified' };
158
166
  }
167
+
168
+ return { valid: true, reason: 'verified' };
159
169
  }
160
170
 
161
171
  /**
@@ -164,14 +174,15 @@ function verifyProcessWindows(proc) {
164
174
  * macOS/Linux: SIGTERM → wait → SIGKILL
165
175
  * Windows: taskkill → wait → taskkill /F
166
176
  */
167
- function killProcess(pid, timeoutMs) {
168
- if (platform === 'win32') {
169
- return killProcessWindows(pid, timeoutMs);
177
+ function killProcess(pid, timeoutMs, options = {}) {
178
+ const runtime = runtimeOptions(options);
179
+ if (runtime.platform === 'win32') {
180
+ return killProcessWindows(pid, timeoutMs, runtime);
170
181
  }
171
- return killProcessUnix(pid, timeoutMs);
182
+ return killProcessUnix(pid, timeoutMs, runtime);
172
183
  }
173
184
 
174
- function killProcessUnix(pid, timeoutMs) {
185
+ function killProcessUnix(pid, timeoutMs, runtime = runtimeOptions()) {
175
186
  try {
176
187
  // Send SIGTERM
177
188
  process.kill(pid, 'SIGTERM');
@@ -189,11 +200,8 @@ function killProcessUnix(pid, timeoutMs) {
189
200
  try {
190
201
  // process.kill(pid, 0) throws if process doesn't exist
191
202
  process.kill(pid, 0);
192
- // Still alive — busy wait in small increments
193
- const waitUntil = Date.now() + 500;
194
- while (Date.now() < waitUntil) {
195
- // Spin wait — we can't use setTimeout synchronously
196
- }
203
+ // Still alive — blocking sleep to avoid CPU spin
204
+ try { runtime.execSync('sleep 0.5', { timeout: 2000 }); } catch { /* ignore */ }
197
205
  } catch {
198
206
  // Process is gone
199
207
  return { success: true, method: 'sigterm' };
@@ -212,37 +220,45 @@ function killProcessUnix(pid, timeoutMs) {
212
220
  }
213
221
  }
214
222
 
215
- function killProcessWindows(pid, timeoutMs) {
223
+ function killProcessWindows(pid, timeoutMs, runtime = runtimeOptions({ platform: 'win32' })) {
224
+ const safePid = normalizePid(pid);
225
+ if (!safePid) {
226
+ return { success: false, error: `Invalid PID: ${pid}` };
227
+ }
228
+
216
229
  try {
217
230
  // Graceful kill
218
- execSync(`taskkill /PID ${pid}`, { encoding: 'utf-8', timeout: 5000 });
231
+ runtime.execSync(`taskkill /PID ${safePid}`, { encoding: 'utf-8', timeout: 5000 });
219
232
  } catch {
220
233
  // Might fail — try force kill directly
221
234
  }
222
235
 
223
236
  // Wait
224
- const deadline = Date.now() + timeoutMs;
225
- while (Date.now() < deadline) {
226
- try {
227
- execSync(`wmic process where ProcessId=${pid} get ProcessId /value`, {
228
- encoding: 'utf-8',
229
- timeout: 3000,
230
- });
231
- // Still alive — wait
232
- const waitUntil = Date.now() + 500;
233
- while (Date.now() < waitUntil) { /* spin */ }
234
- } catch {
237
+ const deadline = runtime.now() + timeoutMs;
238
+ while (runtime.now() < deadline) {
239
+ const exists = windowsProcessExists(safePid, runtime);
240
+ if (exists === false) {
235
241
  return { success: true, method: 'taskkill' };
236
242
  }
243
+ if (exists === null) break;
244
+ try { runtime.execSync('timeout /T 1 /NOBREAK >nul', { timeout: 3000 }); } catch { /* ignore */ }
237
245
  }
238
246
 
239
247
  // Force kill
240
248
  try {
241
- execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf-8', timeout: 5000 });
249
+ runtime.execSync(`taskkill /F /PID ${safePid}`, { encoding: 'utf-8', timeout: 5000 });
242
250
  return { success: true, method: 'taskkill-force' };
243
251
  } catch (err) {
244
252
  return { success: false, error: `Force kill failed: ${err.message}` };
245
253
  }
246
254
  }
247
255
 
256
+ function runtimeOptions(options = {}) {
257
+ return {
258
+ execSync: options.execSync || execSync,
259
+ platform: options.platform || platform,
260
+ now: typeof options.now === 'function' ? options.now : () => Date.now(),
261
+ };
262
+ }
263
+
248
264
  module.exports = { killZombies, verifyProcess, killProcess };