z-clean 0.3.2 → 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 +6 -1
- package/README.md +10 -3
- package/README.zh.md +6 -1
- 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 +4 -1
- 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 -0
- package/src/reporter.js +10 -8
- package/src/scanner.js +6 -2
- package/src/terminal-text.js +11 -0
package/README.ko.md
CHANGED
|
@@ -71,10 +71,12 @@ npx --yes z-clean report
|
|
|
71
71
|
계속 사용하려면 전역 설치하세요:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
npm install
|
|
74
|
+
npm install --global z-clean --foreground-scripts
|
|
75
75
|
zclean report
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
`--foreground-scripts`를 사용하면 zclean 설치 워드마크가 표시됩니다. npm 7 이상은 lifecycle 출력을 기본적으로 숨기므로 `npm install -g z-clean`도 정상 설치되지만 완료 화면은 보이지 않을 수 있습니다.
|
|
79
|
+
|
|
78
80
|
자동 실행은 선택 사항입니다:
|
|
79
81
|
|
|
80
82
|
```bash
|
|
@@ -93,12 +95,15 @@ zclean report --json # 자동화용 JSON report
|
|
|
93
95
|
zclean cache # workspace cache dry-run scan
|
|
94
96
|
zclean cache --yes # 지원되는 cache directory 삭제
|
|
95
97
|
zclean cache --json # cache 후보 JSON 출력
|
|
98
|
+
zclean --pattern=my-agent-worker # 이번 scan에 literal orphan pattern 추가
|
|
96
99
|
zclean history --json # 정리 이력 JSON
|
|
97
100
|
zclean protect list # 보호 목록 확인
|
|
98
101
|
zclean protect add mcp-server-keep
|
|
99
102
|
zclean doctor --json # 설치/탐지/스케줄러 상태 점검
|
|
100
103
|
```
|
|
101
104
|
|
|
105
|
+
패턴을 계속 사용하려면 `~/.zclean/config.json`의 `customPatterns` 배열에 문자열을 추가하세요. 패턴은 정규식이 아닌 대소문자 무시 literal 문자열이며 3–80자의 출력 가능한 문자만 허용됩니다. `node`, `node /`, `ode` 같은 일반 runtime 이름과 부분 문자열은 거부되고, orphan이면서 `maxAge`(기본 24시간)를 넘은 process만 후보가 됩니다. 잘못되거나 0인 기간은 안전한 기본값 24시간으로 돌아갑니다. whitelist, PID 재검증, dry-run, `--yes` 안전장치는 그대로 유지됩니다.
|
|
106
|
+
|
|
102
107
|
다른 workspace cache를 확인하려면:
|
|
103
108
|
|
|
104
109
|
```bash
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ AI coding runtime hygiene
|
|
|
15
15
|
[](#)
|
|
16
16
|
[](#platform-status)
|
|
17
17
|
[](#)
|
|
18
|
-
[](#)
|
|
19
19
|
[](https://github.com/TheStack-ai/zclean)
|
|
20
20
|
[](https://github.com/rohitg00/awesome-claude-code-toolkit)
|
|
21
21
|
|
|
@@ -67,10 +67,12 @@ npx --yes z-clean report
|
|
|
67
67
|
Install the CLI when you are ready to keep using it:
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
npm install
|
|
70
|
+
npm install --global z-clean --foreground-scripts
|
|
71
71
|
zclean report
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
+
`--foreground-scripts` displays zclean's install wordmark. npm 7+ hides lifecycle output by default, so plain `npm install -g z-clean` still installs correctly but may not show the branded completion screen.
|
|
75
|
+
|
|
74
76
|
Clean only when you decide:
|
|
75
77
|
|
|
76
78
|
```bash
|
|
@@ -111,6 +113,7 @@ When an agent crashes, a terminal closes, or a session ends abruptly, those chil
|
|
|
111
113
|
| `zclean cache` | Dry-run scan for supported workspace cache directories |
|
|
112
114
|
| `zclean cache --yes` | Remove supported workspace cache directories |
|
|
113
115
|
| `zclean cache --json` | Machine-readable cache hygiene report |
|
|
116
|
+
| `zclean --pattern=my-agent-worker` | Add one literal orphan-process pattern for this scan |
|
|
114
117
|
| `zclean history` | Recent cleanup history |
|
|
115
118
|
| `zclean history --json` | Sanitized cleanup history and cumulative stats |
|
|
116
119
|
| `zclean protect list` | Show protected process patterns |
|
|
@@ -259,7 +262,8 @@ zclean doctor --json
|
|
|
259
262
|
"sigterm_timeout": 10,
|
|
260
263
|
"dryRunDefault": true,
|
|
261
264
|
"logRetention": "30d",
|
|
262
|
-
"customAiDirs": []
|
|
265
|
+
"customAiDirs": [],
|
|
266
|
+
"customPatterns": []
|
|
263
267
|
}
|
|
264
268
|
```
|
|
265
269
|
|
|
@@ -273,6 +277,9 @@ zclean doctor --json
|
|
|
273
277
|
| `dryRunDefault` | `true` | Manual scans stay dry-run by default; cleanup still requires `--yes` |
|
|
274
278
|
| `logRetention` | `"30d"` | Cleanup history retention |
|
|
275
279
|
| `customAiDirs` | `[]` | Additional AI tool directories to detect, such as `[".mytool"]` |
|
|
280
|
+
| `customPatterns` | `[]` | Case-insensitive literal command fragments for additional orphan processes |
|
|
281
|
+
|
|
282
|
+
Custom patterns are deliberately restricted: values must contain 3–80 printable characters, generic runtime names and fragments such as `node`, `node /`, or `ode` are rejected, and values are treated as literal text rather than regular expressions. Candidates must be orphaned and older than `maxAge` (24 hours by default); invalid or zero durations fall back to that safe default. Whitelists, PID identity verification, batch limits, dry-run behavior, and the explicit `--yes` cleanup gate still apply.
|
|
276
283
|
|
|
277
284
|
## Supported tools
|
|
278
285
|
|
package/README.zh.md
CHANGED
|
@@ -71,10 +71,12 @@ npx --yes z-clean report
|
|
|
71
71
|
准备长期使用时再全局安装:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
npm install
|
|
74
|
+
npm install --global z-clean --foreground-scripts
|
|
75
75
|
zclean report
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
+
`--foreground-scripts` 会显示 zclean 安装字标。npm 7 及以上默认隐藏 lifecycle 输出,因此普通的 `npm install -g z-clean` 仍会正常安装,但可能不显示品牌完成画面。
|
|
79
|
+
|
|
78
80
|
自动运行是可选项:
|
|
79
81
|
|
|
80
82
|
```bash
|
|
@@ -93,12 +95,15 @@ zclean report --json # 自动化 JSON report
|
|
|
93
95
|
zclean cache # workspace cache dry-run scan
|
|
94
96
|
zclean cache --yes # 删除支持的 cache directory
|
|
95
97
|
zclean cache --json # 输出 cache 候选 JSON
|
|
98
|
+
zclean --pattern=my-agent-worker # 为本次扫描添加 literal orphan pattern
|
|
96
99
|
zclean history --json # 清理历史 JSON
|
|
97
100
|
zclean protect list # 查看保护列表
|
|
98
101
|
zclean protect add mcp-server-keep
|
|
99
102
|
zclean doctor --json # 检查安装、调度器和进程枚举状态
|
|
100
103
|
```
|
|
101
104
|
|
|
105
|
+
如需长期使用,请把字符串加入 `~/.zclean/config.json` 的 `customPatterns` 数组。pattern 是忽略大小写的 literal 文本,不是正则表达式;只允许 3–80 个可打印字符,并拒绝 `node`、`node /`、`ode` 等通用 runtime 名称或片段。只有 orphan 且超过 `maxAge`(默认 24 小时)的 process 才会成为候选项;无效或为零的时长会回退到安全的 24 小时默认值。whitelist、PID 再验证、dry-run 和显式 `--yes` 安全限制仍然有效。
|
|
106
|
+
|
|
102
107
|
扫描另一个 workspace:
|
|
103
108
|
|
|
104
109
|
```bash
|
package/bin/zclean.js
CHANGED
|
@@ -6,21 +6,21 @@ const { scan, hasScanErrors } = require('../src/scanner');
|
|
|
6
6
|
const { killZombies } = require('../src/killer');
|
|
7
7
|
const {
|
|
8
8
|
loadConfig,
|
|
9
|
-
saveConfig,
|
|
10
9
|
readLogs,
|
|
11
10
|
pruneLogs,
|
|
12
|
-
DEFAULT_CONFIG,
|
|
13
11
|
appendLog,
|
|
14
12
|
getCumulativeStats,
|
|
15
13
|
getConfigFile,
|
|
16
14
|
} = require('../src/config');
|
|
17
15
|
const { reportDryRun, reportKill, reportStatus, reportLogs, reportConfig, c, bold } = require('../src/reporter');
|
|
18
|
-
const {
|
|
16
|
+
const { removeHook } = require('../src/installer/hook');
|
|
19
17
|
const { runHistory, runProtect } = require('../src/commands/trust');
|
|
20
|
-
const {
|
|
18
|
+
const { uninstallScheduler } = require('../src/commands/scheduler');
|
|
21
19
|
const { runDoctor } = require('../src/doctor');
|
|
22
20
|
const { runAudit } = require('../src/audit');
|
|
23
21
|
const { runCache } = require('../src/cache');
|
|
22
|
+
const { getCustomPatternError, normalizeCustomPattern } = require('../src/detector/patterns');
|
|
23
|
+
const { runInit } = require('../src/commands/init');
|
|
24
24
|
|
|
25
25
|
// Platform-specific installers (lazy loaded)
|
|
26
26
|
const platform = os.platform();
|
|
@@ -33,8 +33,10 @@ const positional = [];
|
|
|
33
33
|
|
|
34
34
|
for (const arg of args) {
|
|
35
35
|
if (arg.startsWith('--')) {
|
|
36
|
-
const
|
|
37
|
-
|
|
36
|
+
const body = arg.substring(2);
|
|
37
|
+
const separator = body.indexOf('=');
|
|
38
|
+
const key = separator === -1 ? body : body.slice(0, separator);
|
|
39
|
+
flags[key] = separator === -1 ? true : body.slice(separator + 1);
|
|
38
40
|
} else if (arg.startsWith('-') && arg.length === 2) {
|
|
39
41
|
flags[arg.substring(1)] = true;
|
|
40
42
|
} else {
|
|
@@ -64,7 +66,7 @@ async function main() {
|
|
|
64
66
|
|
|
65
67
|
switch (command) {
|
|
66
68
|
case 'init':
|
|
67
|
-
return
|
|
69
|
+
return runInit({ config, platform });
|
|
68
70
|
|
|
69
71
|
case 'status':
|
|
70
72
|
return cmdStatus(config);
|
|
@@ -114,17 +116,19 @@ async function main() {
|
|
|
114
116
|
* Dry-run unless --yes is passed.
|
|
115
117
|
*/
|
|
116
118
|
function cmdScan(config) {
|
|
119
|
+
const scanConfig = withPatternFlag(config);
|
|
120
|
+
if (!scanConfig) return;
|
|
117
121
|
const sessionPid = parseSessionPidFlag();
|
|
118
122
|
const force = flags.yes || flags.y;
|
|
119
123
|
|
|
120
124
|
console.log(bold('\n zclean') + c('gray', ' — scanning for zombie processes...\n'));
|
|
121
125
|
|
|
122
|
-
const zombies = scan(
|
|
126
|
+
const zombies = scan(scanConfig, { sessionPid });
|
|
123
127
|
|
|
124
128
|
if (hasScanErrors(zombies)) {
|
|
125
129
|
reportDryRun(zombies);
|
|
126
130
|
appendLog({ action: 'scan-failed', errors: zombies.errors.length });
|
|
127
|
-
pruneLogs(
|
|
131
|
+
pruneLogs(scanConfig);
|
|
128
132
|
process.exitCode = 1;
|
|
129
133
|
return;
|
|
130
134
|
}
|
|
@@ -137,12 +141,12 @@ function cmdScan(config) {
|
|
|
137
141
|
return;
|
|
138
142
|
}
|
|
139
143
|
appendLog({ action: 'scan', found: zombies.length });
|
|
140
|
-
const results = killZombies(zombies,
|
|
144
|
+
const results = killZombies(zombies, scanConfig);
|
|
141
145
|
results.cumulative = getCumulativeStats();
|
|
142
146
|
reportKill(results);
|
|
143
147
|
} else {
|
|
144
148
|
// Dry-run mode
|
|
145
|
-
if (
|
|
149
|
+
if (scanConfig.dryRunDefault === false) {
|
|
146
150
|
console.log(c('yellow', ' dryRunDefault=false is not an auto-kill switch; pass --yes to clean.\n'));
|
|
147
151
|
}
|
|
148
152
|
reportDryRun(zombies);
|
|
@@ -152,40 +156,16 @@ function cmdScan(config) {
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
// Prune old logs
|
|
155
|
-
pruneLogs(
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* init: Install hooks + scheduler.
|
|
160
|
-
*/
|
|
161
|
-
function cmdInit(config) {
|
|
162
|
-
console.log(bold('\n zclean init') + c('gray', ' — installing hooks and scheduler...\n'));
|
|
163
|
-
|
|
164
|
-
// 1. Save default config if none exists
|
|
165
|
-
const existingConfig = loadConfig();
|
|
166
|
-
if (JSON.stringify(existingConfig) === JSON.stringify(DEFAULT_CONFIG)) {
|
|
167
|
-
saveConfig(DEFAULT_CONFIG);
|
|
168
|
-
console.log(c('green', ' Config created:') + ` ${getConfigFile()}`);
|
|
169
|
-
} else {
|
|
170
|
-
console.log(c('gray', ' Config exists:') + ` ${getConfigFile()}`);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// 2. Install Claude Code hook
|
|
174
|
-
const hookResult = installHook();
|
|
175
|
-
const hookIcon = hookResult.installed ? c('green', ' Hook:') : c('yellow', ' Hook:');
|
|
176
|
-
console.log(`${hookIcon} ${hookResult.message}`);
|
|
177
|
-
|
|
178
|
-
// 3. Install platform-specific scheduler
|
|
179
|
-
installScheduler(platform);
|
|
180
|
-
|
|
181
|
-
console.log();
|
|
159
|
+
pruneLogs(scanConfig);
|
|
182
160
|
}
|
|
183
161
|
|
|
184
162
|
/**
|
|
185
163
|
* status: Show current zombies and last cleanup info.
|
|
186
164
|
*/
|
|
187
165
|
function cmdStatus(config) {
|
|
188
|
-
const
|
|
166
|
+
const scanConfig = withPatternFlag(config);
|
|
167
|
+
if (!scanConfig) return;
|
|
168
|
+
const zombies = scan(scanConfig);
|
|
189
169
|
const logs = readLogs(100);
|
|
190
170
|
reportStatus(zombies, logs);
|
|
191
171
|
if (hasScanErrors(zombies)) process.exitCode = 1;
|
|
@@ -232,8 +212,10 @@ function cmdDoctor(config) {
|
|
|
232
212
|
}
|
|
233
213
|
|
|
234
214
|
function cmdAudit(config, commandName = 'audit') {
|
|
215
|
+
const scanConfig = withPatternFlag(config);
|
|
216
|
+
if (!scanConfig) return;
|
|
235
217
|
const sessionPid = parseSessionPidFlag();
|
|
236
|
-
const report = runAudit(
|
|
218
|
+
const report = runAudit(scanConfig, { json: Boolean(flags.json), sessionPid, commandName });
|
|
237
219
|
if (!report.risk.enumerationComplete) process.exitCode = 1;
|
|
238
220
|
}
|
|
239
221
|
|
|
@@ -257,6 +239,19 @@ function parseSessionPidFlag() {
|
|
|
257
239
|
process.exit(1);
|
|
258
240
|
}
|
|
259
241
|
|
|
242
|
+
function withPatternFlag(config) {
|
|
243
|
+
if (flags.pattern === undefined) return config;
|
|
244
|
+
const error = getCustomPatternError(flags.pattern);
|
|
245
|
+
if (error) {
|
|
246
|
+
console.error(c('red', ` ${error}`));
|
|
247
|
+
process.exitCode = 1;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const literal = normalizeCustomPattern(flags.pattern);
|
|
251
|
+
const configured = Array.isArray(config.customPatterns) ? config.customPatterns : [];
|
|
252
|
+
return { ...config, customPatterns: [...configured, literal] };
|
|
253
|
+
}
|
|
254
|
+
|
|
260
255
|
// ─── Help ───────────────────────────────────────────────────────────────────
|
|
261
256
|
|
|
262
257
|
function printHelp() {
|
|
@@ -285,6 +280,7 @@ function printHelp() {
|
|
|
285
280
|
${bold('Options:')}
|
|
286
281
|
--yes, -y Kill found zombies (default: dry-run)
|
|
287
282
|
--session-pid=PID Filter by parent session PID
|
|
283
|
+
--pattern=TEXT Add a literal orphan-process pattern
|
|
288
284
|
--path=DIR Workspace path for zclean cache
|
|
289
285
|
--json Print machine-readable output for supported commands
|
|
290
286
|
--version, -v Show version
|
package/package.json
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "z-clean",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "AI coding runtime hygiene reports, zombie cleanup, and safe workspace cache cleanup",
|
|
5
5
|
"bin": {
|
|
6
6
|
"zclean": "bin/zclean.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
|
-
"test": "node --test test/patterns.test.js test/scanner.test.js test/process-tree.test.js test/killer.test.js test/config.test.js test/installer-fallback.test.js test/cli.test.js test/cli-history-protect.test.js test/cli-doctor.test.js test/audit.test.js test/cache.test.js",
|
|
9
|
+
"test": "node --test test/patterns.test.js test/scanner.test.js test/process-tree.test.js test/killer.test.js test/config.test.js test/installer-fallback.test.js test/install-experience.test.js test/reporter.test.js test/cli.test.js test/cli-history-protect.test.js test/cli-doctor.test.js test/audit.test.js test/cache.test.js",
|
|
10
10
|
"syntax": "node -e \"const fs=require('node:fs');const path=require('node:path');const {execFileSync}=require('node:child_process');const files=['bin/zclean.js'];function walk(dir){if(!fs.existsSync(dir))return;for(const entry of fs.readdirSync(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory())walk(file);else if(entry.name.endsWith('.js'))files.push(file);}}for(const dir of ['src','test','scripts'])walk(dir);for(const file of files)execFileSync(process.execPath,['-c',file],{stdio:'inherit'});\"",
|
|
11
11
|
"smoke": "node scripts/smoke.js",
|
|
12
|
-
"pack:check": "npm pack --dry-run --json"
|
|
12
|
+
"pack:check": "npm pack --dry-run --json",
|
|
13
|
+
"postinstall": "node scripts/postinstall.js"
|
|
13
14
|
},
|
|
14
15
|
"keywords": [
|
|
15
16
|
"claude-code",
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
"files": [
|
|
41
42
|
"bin/",
|
|
42
43
|
"src/",
|
|
44
|
+
"scripts/postinstall.js",
|
|
43
45
|
"assets/zclean-hero.png",
|
|
44
46
|
"LICENSE",
|
|
45
47
|
"README.md",
|
package/src/cli-brand.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { c, bold } = require('./reporter');
|
|
4
|
+
const { sanitizeTerminalText } = require('./terminal-text');
|
|
5
|
+
|
|
6
|
+
const WORDMARK = [
|
|
7
|
+
' ███████ ██████ ██ ███████ █████ ███ ██',
|
|
8
|
+
' ██ ██ ██ ██ ██ ██ ████ ██',
|
|
9
|
+
' ███ ██ ██ █████ ███████ ██ ██ ██',
|
|
10
|
+
' ██ ██ ██ ██ ██ ██ ██ ██ ██',
|
|
11
|
+
' ███████ ██████ ███████ ███████ ██ ██ ██ ████',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const RULE = ' --------------------------------------------------------';
|
|
15
|
+
const FRAME_WIDTH = 55;
|
|
16
|
+
|
|
17
|
+
function renderPostinstall({ version }) {
|
|
18
|
+
return [
|
|
19
|
+
'',
|
|
20
|
+
c('gray', ' . + .'),
|
|
21
|
+
c('gray', ' + . :'),
|
|
22
|
+
...WORDMARK.map((line) => c('cyan', line)),
|
|
23
|
+
'',
|
|
24
|
+
` ${bold('Z / CLEAN')} ${c('gray', `v${version}`)}`,
|
|
25
|
+
c('gray', ' AI CODING RUNTIME HYGIENE'),
|
|
26
|
+
c('gray', RULE),
|
|
27
|
+
'',
|
|
28
|
+
` ${c('green', 'INSTALLED')} z-clean`,
|
|
29
|
+
` ${c('gray', 'COMMAND')} zclean`,
|
|
30
|
+
` ${c('gray', 'SAFETY')} dry-run by default`,
|
|
31
|
+
'',
|
|
32
|
+
c('gray', ' Continue setup'),
|
|
33
|
+
` ${c('cyan', '$ zclean init')}`,
|
|
34
|
+
'',
|
|
35
|
+
c('gray', ' Inspect first. Clean only when you decide.'),
|
|
36
|
+
'',
|
|
37
|
+
].join('\n');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderInit({ version, steps, warningCount }) {
|
|
41
|
+
const lines = [
|
|
42
|
+
'',
|
|
43
|
+
c('gray', ' . + .'),
|
|
44
|
+
c('gray', ' + . :'),
|
|
45
|
+
...WORDMARK.map((line) => c('cyan', line)),
|
|
46
|
+
'',
|
|
47
|
+
` ${bold('Z / CLEAN')} ${c('gray', `v${version}`)}`,
|
|
48
|
+
c('gray', ' RUNTIME HYGIENE SYSTEM'),
|
|
49
|
+
'',
|
|
50
|
+
c('gray', ` +${'-- SETUP '.padEnd(FRAME_WIDTH, '-')}+`),
|
|
51
|
+
c('gray', ` |${''.padEnd(FRAME_WIDTH)}|`),
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
for (const step of steps) {
|
|
55
|
+
lines.push(renderStep(step));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ready = warningCount === 0;
|
|
59
|
+
lines.push(
|
|
60
|
+
c('gray', ` |${''.padEnd(FRAME_WIDTH)}|`),
|
|
61
|
+
c('gray', ` +${''.padEnd(FRAME_WIDTH, '-')}+`),
|
|
62
|
+
'',
|
|
63
|
+
ready
|
|
64
|
+
? c('green', ' SYSTEM READY')
|
|
65
|
+
: c('yellow', ` SETUP COMPLETED WITH ${warningCount} WARNING${warningCount === 1 ? '' : 'S'}`),
|
|
66
|
+
'',
|
|
67
|
+
` ${c('gray', 'Inspect runtime leftovers')} ${c('cyan', 'zclean audit')}`,
|
|
68
|
+
` ${c('gray', 'Verify this installation')} ${c('cyan', 'zclean doctor')}`,
|
|
69
|
+
'',
|
|
70
|
+
c('gray', ' Cleanup remains locked until you pass --yes.'),
|
|
71
|
+
''
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderStep(step) {
|
|
78
|
+
const state = String(step.state).padEnd(10);
|
|
79
|
+
const label = String(step.label).padEnd(14);
|
|
80
|
+
const tone = step.state === 'WARNING' ? 'yellow' : step.state === 'EXISTS' ? 'gray' : 'green';
|
|
81
|
+
const statusPrefix = ` ${step.index} ${label} `;
|
|
82
|
+
const statusSpacing = ''.padEnd(FRAME_WIDTH - statusPrefix.length - state.length);
|
|
83
|
+
const detail = padToWidth(` ${fitDetail(step.detail)}`, FRAME_WIDTH);
|
|
84
|
+
return [
|
|
85
|
+
` |${c('gray', statusPrefix)}${c(tone, state)}${statusSpacing}|`,
|
|
86
|
+
` |${c('gray', detail)}|`,
|
|
87
|
+
].join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function fitDetail(value) {
|
|
91
|
+
const text = sanitizeTerminalText(value).replace(/\s+/g, ' ').trim();
|
|
92
|
+
if (displayWidth(text) <= 45) return text;
|
|
93
|
+
let output = '';
|
|
94
|
+
let width = 0;
|
|
95
|
+
for (const char of text) {
|
|
96
|
+
const next = charWidth(char);
|
|
97
|
+
if (width + next > 42) break;
|
|
98
|
+
output += char;
|
|
99
|
+
width += next;
|
|
100
|
+
}
|
|
101
|
+
return `${output}...`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function padToWidth(value, width) {
|
|
105
|
+
return `${value}${''.padEnd(Math.max(0, width - displayWidth(value)))}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function displayWidth(value) {
|
|
109
|
+
return [...value].reduce((width, char) => width + charWidth(char), 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function charWidth(char) {
|
|
113
|
+
if (/\p{Mark}/u.test(char)) return 0;
|
|
114
|
+
const code = char.codePointAt(0);
|
|
115
|
+
const wide = code >= 0x1100 && (
|
|
116
|
+
code <= 0x115f || code === 0x2329 || code === 0x232a ||
|
|
117
|
+
(code >= 0x2e80 && code <= 0xa4cf && code !== 0x303f) ||
|
|
118
|
+
(code >= 0xac00 && code <= 0xd7a3) ||
|
|
119
|
+
(code >= 0xf900 && code <= 0xfaff) ||
|
|
120
|
+
(code >= 0xfe10 && code <= 0xfe6f) ||
|
|
121
|
+
(code >= 0xff00 && code <= 0xff60) ||
|
|
122
|
+
(code >= 0xffe0 && code <= 0xffe6) ||
|
|
123
|
+
(code >= 0x1f300 && code <= 0x1f9ff) ||
|
|
124
|
+
(code >= 0x20000 && code <= 0x3fffd)
|
|
125
|
+
);
|
|
126
|
+
return wide ? 2 : 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { renderPostinstall, renderInit };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const { saveConfig, getConfigFile } = require('../config');
|
|
6
|
+
const { installHook } = require('../installer/hook');
|
|
7
|
+
const { installScheduler, getSchedulerState } = require('./scheduler');
|
|
8
|
+
const { renderInit } = require('../cli-brand');
|
|
9
|
+
const { version } = require('../../package.json');
|
|
10
|
+
|
|
11
|
+
function runInit({ config, platform }) {
|
|
12
|
+
const configFile = getConfigFile();
|
|
13
|
+
const configExists = fs.existsSync(configFile);
|
|
14
|
+
if (!configExists) saveConfig(config);
|
|
15
|
+
|
|
16
|
+
const hookResult = installHook();
|
|
17
|
+
const schedulerResult = installScheduler(platform);
|
|
18
|
+
const schedulerState = getSchedulerState(schedulerResult);
|
|
19
|
+
const steps = [
|
|
20
|
+
{
|
|
21
|
+
index: '01',
|
|
22
|
+
label: 'CONFIG',
|
|
23
|
+
state: configExists ? 'EXISTS' : 'READY',
|
|
24
|
+
detail: configFile.replace(os.homedir(), '~'),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
index: '02',
|
|
28
|
+
label: 'CLAUDE HOOK',
|
|
29
|
+
state: hookResult.installed ? 'INSTALLED' : 'WARNING',
|
|
30
|
+
detail: hookResult.installed ? 'SessionEnd cleanup hook registered' : hookResult.message,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
index: '03',
|
|
34
|
+
label: 'SCHEDULER',
|
|
35
|
+
state: schedulerState,
|
|
36
|
+
detail: schedulerState === 'ACTIVE' ? schedulerLabel(platform) : schedulerResult.message,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
const warningCount = steps.filter((step) => step.state === 'WARNING').length;
|
|
40
|
+
console.log(renderInit({ version, steps, warningCount }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function schedulerLabel(platform) {
|
|
44
|
+
if (platform === 'darwin') return 'Hourly launchd hygiene check';
|
|
45
|
+
if (platform === 'linux') return 'Hourly systemd hygiene timer';
|
|
46
|
+
if (platform === 'win32') return 'Hourly Task Scheduler check';
|
|
47
|
+
return 'Runtime hygiene scheduler';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { runInit };
|
|
@@ -6,53 +6,57 @@ function installScheduler(platform) {
|
|
|
6
6
|
switch (platform) {
|
|
7
7
|
case 'darwin': {
|
|
8
8
|
const { installLaunchd } = require('../installer/launchd');
|
|
9
|
-
|
|
10
|
-
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
11
|
-
console.log(`${icon} ${result.message}`);
|
|
12
|
-
break;
|
|
9
|
+
return installLaunchd();
|
|
13
10
|
}
|
|
14
11
|
case 'linux': {
|
|
15
12
|
const { installSystemd } = require('../installer/systemd');
|
|
16
|
-
|
|
17
|
-
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
18
|
-
console.log(`${icon} ${result.message}`);
|
|
19
|
-
break;
|
|
13
|
+
return installSystemd();
|
|
20
14
|
}
|
|
21
15
|
case 'win32': {
|
|
22
16
|
const { installTaskScheduler } = require('../installer/taskscheduler');
|
|
23
|
-
|
|
24
|
-
const icon = result.installed ? c('green', ' Scheduler:') : c('yellow', ' Scheduler:');
|
|
25
|
-
console.log(`${icon} ${result.message}`);
|
|
26
|
-
break;
|
|
17
|
+
return installTaskScheduler();
|
|
27
18
|
}
|
|
28
19
|
default:
|
|
29
|
-
|
|
20
|
+
return {
|
|
21
|
+
installed: false,
|
|
22
|
+
message: `Unsupported platform (${platform}). Install a cron job manually.`,
|
|
23
|
+
};
|
|
30
24
|
}
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
function uninstallScheduler(platform) {
|
|
27
|
+
function uninstallScheduler(platform, options = {}) {
|
|
28
|
+
const log = options.log || console.log;
|
|
29
|
+
let result;
|
|
34
30
|
switch (platform) {
|
|
35
31
|
case 'darwin': {
|
|
36
32
|
const { removeLaunchd } = require('../installer/launchd');
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
result = (options.remove || removeLaunchd)();
|
|
34
|
+
log(` Scheduler: ${result.message}`);
|
|
39
35
|
break;
|
|
40
36
|
}
|
|
41
37
|
case 'linux': {
|
|
42
38
|
const { removeSystemd } = require('../installer/systemd');
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
result = (options.remove || removeSystemd)();
|
|
40
|
+
log(` Scheduler: ${result.message}`);
|
|
45
41
|
break;
|
|
46
42
|
}
|
|
47
43
|
case 'win32': {
|
|
48
44
|
const { removeTaskScheduler } = require('../installer/taskscheduler');
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
result = (options.remove || removeTaskScheduler)();
|
|
46
|
+
log(` Scheduler: ${result.message}`);
|
|
51
47
|
break;
|
|
52
48
|
}
|
|
53
|
-
default:
|
|
54
|
-
|
|
49
|
+
default: {
|
|
50
|
+
result = { removed: false, failed: false, message: `Remove manually for ${platform}.` };
|
|
51
|
+
log(c('yellow', ` Scheduler: ${result.message}`));
|
|
52
|
+
}
|
|
55
53
|
}
|
|
54
|
+
if (result.failed) process.exitCode = 1;
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSchedulerState(result) {
|
|
59
|
+
return result.installed && result.active !== false ? 'ACTIVE' : 'WARNING';
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
module.exports = { installScheduler, uninstallScheduler };
|
|
62
|
+
module.exports = { installScheduler, uninstallScheduler, getSchedulerState };
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,7 @@ const DEFAULT_CONFIG = {
|
|
|
40
40
|
logRetention: '30d',
|
|
41
41
|
maxKillBatch: 20,
|
|
42
42
|
customAiDirs: [],
|
|
43
|
+
customPatterns: [],
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -91,7 +92,9 @@ function loadConfig() {
|
|
|
91
92
|
try {
|
|
92
93
|
const raw = fs.readFileSync(configFile, 'utf-8');
|
|
93
94
|
const userConfig = JSON.parse(raw);
|
|
94
|
-
|
|
95
|
+
const config = { ...DEFAULT_CONFIG, ...userConfig };
|
|
96
|
+
if (!(parseDuration(config.maxAge) > 0)) config.maxAge = DEFAULT_CONFIG.maxAge;
|
|
97
|
+
return config;
|
|
95
98
|
} catch {
|
|
96
99
|
// Corrupted config — use defaults
|
|
97
100
|
return { ...DEFAULT_CONFIG };
|
|
@@ -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
|
@@ -118,6 +118,9 @@ function verifyProcessUnix(proc, runtime = runtimeOptions()) {
|
|
|
118
118
|
if (scanPrefix !== currentPrefix) {
|
|
119
119
|
return { valid: false, reason: 'cmd-mismatch' };
|
|
120
120
|
}
|
|
121
|
+
if (!matchesCustomLiteral(proc, cmd)) {
|
|
122
|
+
return { valid: false, reason: 'pattern-mismatch' };
|
|
123
|
+
}
|
|
121
124
|
|
|
122
125
|
// Verify start time if available
|
|
123
126
|
if (proc.startTime) {
|
|
@@ -154,6 +157,9 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
|
|
|
154
157
|
if (scanPrefix !== currentPrefix) {
|
|
155
158
|
return { valid: false, reason: 'cmd-mismatch' };
|
|
156
159
|
}
|
|
160
|
+
if (!matchesCustomLiteral(proc, currentCmd)) {
|
|
161
|
+
return { valid: false, reason: 'pattern-mismatch' };
|
|
162
|
+
}
|
|
157
163
|
|
|
158
164
|
if (proc.startTime && current.startTime && current.startTime !== proc.startTime) {
|
|
159
165
|
return { valid: false, reason: 'start-time-mismatch' };
|
|
@@ -166,6 +172,11 @@ function verifyProcessWindows(proc, runtime = runtimeOptions({ platform: 'win32'
|
|
|
166
172
|
return { valid: true, reason: 'verified' };
|
|
167
173
|
}
|
|
168
174
|
|
|
175
|
+
function matchesCustomLiteral(proc, currentCmd) {
|
|
176
|
+
if (!proc.matchLiteral) return true;
|
|
177
|
+
return String(currentCmd).toLowerCase().includes(String(proc.matchLiteral).toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
|
|
169
180
|
/**
|
|
170
181
|
* Kill a process with graceful shutdown sequence.
|
|
171
182
|
*
|
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();
|
package/src/scanner.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ProcessTree } = require('./process-tree');
|
|
4
|
-
const {
|
|
4
|
+
const { createPatternMatcher, AI_DIR_REGEX, buildAiDirRegex } = require('./detector/patterns');
|
|
5
5
|
const { isWhitelisted } = require('./detector/whitelist');
|
|
6
6
|
const { parseDuration, parseMemory } = require('./config');
|
|
7
7
|
|
|
@@ -18,6 +18,7 @@ const { parseDuration, parseMemory } = require('./config');
|
|
|
18
18
|
function scan(config, opts = {}) {
|
|
19
19
|
const tree = ProcessTree.build();
|
|
20
20
|
const aiDirRegex = buildAiDirRegex(config.customAiDirs);
|
|
21
|
+
const matchConfiguredPattern = createPatternMatcher(config);
|
|
21
22
|
const zombies = [];
|
|
22
23
|
const warnings = Array.isArray(tree.warnings) ? [...tree.warnings] : [];
|
|
23
24
|
const errors = Array.isArray(tree.errors) ? [...tree.errors] : [];
|
|
@@ -35,7 +36,7 @@ function scan(config, opts = {}) {
|
|
|
35
36
|
// Iterate all processes in the tree
|
|
36
37
|
for (const [, proc] of tree.byPid) {
|
|
37
38
|
// Match against known AI tool patterns
|
|
38
|
-
const pattern =
|
|
39
|
+
const pattern = matchConfiguredPattern(proc.cmd);
|
|
39
40
|
if (!pattern) continue;
|
|
40
41
|
|
|
41
42
|
// AI path filter: skip generic patterns if command isn't in an AI tool directory
|
|
@@ -47,6 +48,8 @@ function scan(config, opts = {}) {
|
|
|
47
48
|
? tree.hasAncestorMatching(proc.pid, (p) => p.pid === sessionPid)
|
|
48
49
|
: false;
|
|
49
50
|
|
|
51
|
+
if (pattern.strictOrphan && !orphanResult.isOrphan) continue;
|
|
52
|
+
|
|
50
53
|
// If pattern requires orphan status and process isn't orphaned, skip
|
|
51
54
|
if (pattern.orphanOnly && !orphanResult.isOrphan && !sessionRelated) continue;
|
|
52
55
|
|
|
@@ -90,6 +93,7 @@ function scan(config, opts = {}) {
|
|
|
90
93
|
startTime: proc.startTime,
|
|
91
94
|
reason: reasons.join(', '),
|
|
92
95
|
pattern: pattern.name,
|
|
96
|
+
matchLiteral: pattern.literal || null,
|
|
93
97
|
});
|
|
94
98
|
}
|
|
95
99
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function sanitizeTerminalText(value) {
|
|
4
|
+
return String(value ?? '')
|
|
5
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
|
6
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
7
|
+
.replace(/[\r\n]+/g, ' ')
|
|
8
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = { sanitizeTerminalText };
|