@link-assistant/hive-mind 2.1.0 ā 2.1.1
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/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/cleanup.mjs +2 -2
- package/src/cleanup.os.lib.mjs +31 -31
- package/src/system-cleanup-estimates.lib.mjs +256 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 26e3410: Report estimated reclaimable space for `hive-cleanup --dry-run` system cleanup
|
|
8
|
+
commands and await system-cleanup logging so dry-run output stays in order.
|
|
9
|
+
|
|
3
10
|
## 2.1.0
|
|
4
11
|
|
|
5
12
|
### Minor Changes
|
package/package.json
CHANGED
package/src/cleanup.mjs
CHANGED
|
@@ -422,8 +422,8 @@ async function main() {
|
|
|
422
422
|
|
|
423
423
|
// 8. System / Ubuntu cleanup (opt-in).
|
|
424
424
|
if (options.apt || options.journal || options.docker || options.npm) {
|
|
425
|
-
await log('\nš§“ System cleanup:');
|
|
426
|
-
runSystemCleanup({
|
|
425
|
+
await log(options.dryRun ? '\nš§“ System cleanup (dry-run, estimated reclaim):' : '\nš§“ System cleanup:');
|
|
426
|
+
await runSystemCleanup({
|
|
427
427
|
apt: options.apt,
|
|
428
428
|
journal: options.journal,
|
|
429
429
|
docker: options.docker,
|
package/src/cleanup.os.lib.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
21
21
|
|
|
22
22
|
import { extractTaskRefsFromCommand, isDockerIsolationSessionName, parseDockerContainerExitCode, parseRemoteUrl } from './cleanup.lib.mjs';
|
|
23
23
|
import { correlateProcesses, parseStartCommandLogMetadata, redactProcessText } from './process-debug.lib.mjs';
|
|
24
|
+
import { buildSystemCleanupPlan, estimateSystemCleanupPlan, formatSystemCleanupEstimateLine, formatSystemCleanupTotalLine } from './system-cleanup-estimates.lib.mjs';
|
|
24
25
|
|
|
25
26
|
/** Run a command, returning trimmed stdout or null on any failure. */
|
|
26
27
|
function tryExec(cmd, args, options = {}) {
|
|
@@ -867,7 +868,7 @@ export function removeDockerContainer(containerName) {
|
|
|
867
868
|
|
|
868
869
|
/**
|
|
869
870
|
* System / Ubuntu cleanup actions. Each is opt-in. In dry-run mode the commands
|
|
870
|
-
* are
|
|
871
|
+
* are estimated and described, never executed.
|
|
871
872
|
*
|
|
872
873
|
* @param {Object} options
|
|
873
874
|
* @param {boolean} [options.apt] - apt-get clean / autoclean / autoremove
|
|
@@ -877,41 +878,40 @@ export function removeDockerContainer(containerName) {
|
|
|
877
878
|
* @param {string} [options.journalVacuumTime='2weeks']
|
|
878
879
|
* @param {boolean} [options.dryRun]
|
|
879
880
|
* @param {boolean} [options.useSudo] - prefix package commands with sudo
|
|
880
|
-
* @param {(msg: string) => void} [options.logFn]
|
|
881
|
-
* @returns {Array<{command: string, executed: boolean, ok: boolean|null}
|
|
881
|
+
* @param {(msg: string) => void|Promise<void>} [options.logFn]
|
|
882
|
+
* @returns {Promise<Array<{command: string, executed: boolean, ok: boolean|null, estimatedBytes?: number|null}>>}
|
|
882
883
|
*/
|
|
883
|
-
export function runSystemCleanup(options = {}) {
|
|
884
|
-
const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {} } = options;
|
|
884
|
+
export async function runSystemCleanup(options = {}) {
|
|
885
|
+
const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', dryRun = false, useSudo = false, logFn = () => {}, execFn = tryExec } = options;
|
|
886
|
+
const plan = buildSystemCleanupPlan({ apt, journal, docker, npm, journalVacuumTime, useSudo });
|
|
887
|
+
const results = [];
|
|
885
888
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
889
|
+
if (dryRun) {
|
|
890
|
+
const estimates = estimateSystemCleanupPlan(plan, {
|
|
891
|
+
execFn,
|
|
892
|
+
journalFiles: options.journalFiles,
|
|
893
|
+
now: options.now || new Date(),
|
|
894
|
+
});
|
|
895
|
+
for (const estimate of estimates) {
|
|
896
|
+
await logFn(formatSystemCleanupEstimateLine(estimate));
|
|
897
|
+
results.push({
|
|
898
|
+
command: estimate.command,
|
|
899
|
+
executed: false,
|
|
900
|
+
ok: null,
|
|
901
|
+
estimatedBytes: estimate.estimatedBytes,
|
|
902
|
+
detail: estimate.detail,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
await logFn(formatSystemCleanupTotalLine(estimates));
|
|
906
|
+
return results;
|
|
901
907
|
}
|
|
902
908
|
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
logFn(` [dry-run] would run: ${display}`);
|
|
908
|
-
results.push({ command: display, executed: false, ok: null });
|
|
909
|
-
continue;
|
|
910
|
-
}
|
|
911
|
-
logFn(` running: ${display}`);
|
|
912
|
-
const out = tryExec(argv[0], argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
909
|
+
for (const item of plan) {
|
|
910
|
+
const display = item.argv.join(' ');
|
|
911
|
+
await logFn(` running: ${display}`);
|
|
912
|
+
const out = execFn(item.argv[0], item.argv.slice(1), { timeout: 180000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
913
913
|
const ok = out !== null;
|
|
914
|
-
logFn(ok ? ` ā ${display}` : ` ā ${display} (failed or unavailable)`);
|
|
914
|
+
await logFn(ok ? ` ā ${display}` : ` ā ${display} (failed or unavailable)`);
|
|
915
915
|
results.push({ command: display, executed: true, ok });
|
|
916
916
|
}
|
|
917
917
|
return results;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { formatBytes } from './cleanup.lib.mjs';
|
|
5
|
+
|
|
6
|
+
const APT_ARCHIVES_PATH = '/var/cache/apt/archives';
|
|
7
|
+
const JOURNAL_ROOTS = ['/var/log/journal', '/run/log/journal'];
|
|
8
|
+
|
|
9
|
+
const UNIT_MULTIPLIERS = new Map([
|
|
10
|
+
['', 1],
|
|
11
|
+
['B', 1],
|
|
12
|
+
['BYTE', 1],
|
|
13
|
+
['BYTES', 1],
|
|
14
|
+
['K', 1024],
|
|
15
|
+
['KB', 1024],
|
|
16
|
+
['KIB', 1024],
|
|
17
|
+
['M', 1024 ** 2],
|
|
18
|
+
['MB', 1024 ** 2],
|
|
19
|
+
['MIB', 1024 ** 2],
|
|
20
|
+
['G', 1024 ** 3],
|
|
21
|
+
['GB', 1024 ** 3],
|
|
22
|
+
['GIB', 1024 ** 3],
|
|
23
|
+
['T', 1024 ** 4],
|
|
24
|
+
['TB', 1024 ** 4],
|
|
25
|
+
['TIB', 1024 ** 4],
|
|
26
|
+
['P', 1024 ** 5],
|
|
27
|
+
['PB', 1024 ** 5],
|
|
28
|
+
['PIB', 1024 ** 5],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
export function parseHumanBytes(value) {
|
|
32
|
+
const match = String(value ?? '')
|
|
33
|
+
.trim()
|
|
34
|
+
.match(/^([0-9][0-9,]*(?:\.[0-9]+)?)\s*([KMGTPE]?i?B?|bytes?)?/i);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const number = Number(match[1].replace(/,/g, ''));
|
|
37
|
+
if (!Number.isFinite(number)) return null;
|
|
38
|
+
const unit = String(match[2] || 'B')
|
|
39
|
+
.toUpperCase()
|
|
40
|
+
.replace(/IB$/, 'IB');
|
|
41
|
+
const multiplier = UNIT_MULTIPLIERS.get(unit);
|
|
42
|
+
if (!multiplier) return null;
|
|
43
|
+
return Math.round(number * multiplier);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseDuBytes(output, blockSize = 1) {
|
|
47
|
+
const token = String(output ?? '')
|
|
48
|
+
.trim()
|
|
49
|
+
.split(/\s+/)[0];
|
|
50
|
+
const value = Number(token?.replace(/,/g, ''));
|
|
51
|
+
return Number.isFinite(value) ? Math.round(value * blockSize) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseAptAutoremoveFreedBytes(output) {
|
|
55
|
+
const text = String(output || '');
|
|
56
|
+
const freed = text.match(/After this operation,\s+([0-9][0-9,]*(?:\.[0-9]+)?\s*[KMGTPE]?i?B?)\s+disk space will be freed/i);
|
|
57
|
+
if (freed) return parseHumanBytes(freed[1]);
|
|
58
|
+
if (/\b0\s+to\s+remove\b/i.test(text)) return 0;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseJournalDiskUsageBytes(output) {
|
|
63
|
+
const match = String(output || '').match(/\btake up\s+([0-9][0-9,]*(?:\.[0-9]+)?\s*[KMGTPE]?i?B?)\b/i);
|
|
64
|
+
return match ? parseHumanBytes(match[1]) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseDockerSystemDf(output) {
|
|
68
|
+
const items = [];
|
|
69
|
+
for (const line of String(output ?? '').split('\n')) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!trimmed || /^TYPE\s+/i.test(trimmed)) continue;
|
|
72
|
+
const parts = trimmed.split(/\s{2,}/).filter(Boolean);
|
|
73
|
+
if (parts.length < 5) continue;
|
|
74
|
+
const reclaimable = parts[4];
|
|
75
|
+
const reclaimableBytes = parseHumanBytes(reclaimable);
|
|
76
|
+
if (reclaimableBytes == null) continue;
|
|
77
|
+
items.push({
|
|
78
|
+
type: parts[0],
|
|
79
|
+
total: parts[1],
|
|
80
|
+
active: parts[2],
|
|
81
|
+
size: parts[3],
|
|
82
|
+
reclaimable,
|
|
83
|
+
reclaimableBytes,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
items,
|
|
88
|
+
totalReclaimableBytes: items.reduce((sum, item) => sum + item.reclaimableBytes, 0),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildSystemCleanupPlan(options = {}) {
|
|
93
|
+
const { apt = false, journal = false, docker = false, npm = false, journalVacuumTime = '2weeks', useSudo = false } = options;
|
|
94
|
+
const sudo = useSudo ? ['sudo'] : [];
|
|
95
|
+
const plan = [];
|
|
96
|
+
if (apt) {
|
|
97
|
+
plan.push({ action: 'apt-clean', category: 'apt', argv: [...sudo, 'apt-get', 'clean'] });
|
|
98
|
+
plan.push({ action: 'apt-autoclean', category: 'apt', argv: [...sudo, 'apt-get', 'autoclean', '-y'] });
|
|
99
|
+
plan.push({ action: 'apt-autoremove', category: 'apt', argv: [...sudo, 'apt-get', 'autoremove', '-y'] });
|
|
100
|
+
}
|
|
101
|
+
if (journal) {
|
|
102
|
+
plan.push({
|
|
103
|
+
action: 'journal-vacuum',
|
|
104
|
+
category: 'journal',
|
|
105
|
+
argv: [...sudo, 'journalctl', `--vacuum-time=${journalVacuumTime}`],
|
|
106
|
+
journalVacuumTime,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (docker) plan.push({ action: 'docker-prune', category: 'docker', argv: ['docker', 'system', 'prune', '-f'] });
|
|
110
|
+
if (npm) plan.push({ action: 'npm-cache-clean', category: 'npm', argv: ['npm', 'cache', 'clean', '--force'] });
|
|
111
|
+
return plan;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function commandDisplay(argv) {
|
|
115
|
+
return argv.join(' ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function measurePathBytes(targetPath, execFn) {
|
|
119
|
+
const exact = parseDuBytes(execFn('du', ['-sb', targetPath]));
|
|
120
|
+
if (exact != null) return exact;
|
|
121
|
+
const kib = parseDuBytes(execFn('du', ['-sk', targetPath]), 1024);
|
|
122
|
+
if (kib != null) return kib;
|
|
123
|
+
try {
|
|
124
|
+
return fs.statSync(targetPath).size;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function parseDurationMs(spec) {
|
|
131
|
+
let total = 0;
|
|
132
|
+
const pattern = /([0-9]+(?:\.[0-9]+)?)\s*(microseconds?|usec|milliseconds?|msec|ms|seconds?|secs?|sec|s|minutes?|mins?|min|m|hours?|hrs?|hr|h|days?|d|weeks?|w|months?|years?|yrs?|yr|y)/gi;
|
|
133
|
+
let match;
|
|
134
|
+
while ((match = pattern.exec(String(spec || ''))) !== null) {
|
|
135
|
+
const value = Number(match[1]);
|
|
136
|
+
if (!Number.isFinite(value)) continue;
|
|
137
|
+
const unit = match[2].toLowerCase();
|
|
138
|
+
if (unit.startsWith('micro') || unit === 'usec') total += value / 1000;
|
|
139
|
+
else if (unit.startsWith('milli') || unit === 'msec' || unit === 'ms') total += value;
|
|
140
|
+
else if (unit === 'm' || unit.startsWith('min')) total += value * 60 * 1000;
|
|
141
|
+
else if (unit === 'h' || unit.startsWith('h')) total += value * 60 * 60 * 1000;
|
|
142
|
+
else if (unit === 'd' || unit.startsWith('day')) total += value * 24 * 60 * 60 * 1000;
|
|
143
|
+
else if (unit === 'w' || unit.startsWith('week')) total += value * 7 * 24 * 60 * 60 * 1000;
|
|
144
|
+
else if (unit.startsWith('month')) total += value * 30 * 24 * 60 * 60 * 1000;
|
|
145
|
+
else if (unit === 'y' || unit.startsWith('yr') || unit.startsWith('year')) total += value * 365 * 24 * 60 * 60 * 1000;
|
|
146
|
+
else total += value * 1000;
|
|
147
|
+
}
|
|
148
|
+
return total > 0 ? total : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function listJournalFiles(roots = JOURNAL_ROOTS) {
|
|
152
|
+
const files = [];
|
|
153
|
+
const stack = [...roots];
|
|
154
|
+
while (stack.length > 0) {
|
|
155
|
+
const current = stack.pop();
|
|
156
|
+
let stat;
|
|
157
|
+
try {
|
|
158
|
+
stat = fs.statSync(current);
|
|
159
|
+
} catch {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (stat.isDirectory()) {
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
for (const entry of entries) stack.push(path.join(current, entry.name));
|
|
170
|
+
} else if (stat.isFile() && /\.journal~?$/.test(current)) {
|
|
171
|
+
files.push({ path: current, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return files;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function estimateJournalBytes({ execFn, journalVacuumTime, journalFiles, now }) {
|
|
178
|
+
const currentBytes = parseJournalDiskUsageBytes(execFn('journalctl', ['--disk-usage']));
|
|
179
|
+
const durationMs = parseDurationMs(journalVacuumTime);
|
|
180
|
+
const files = journalFiles ?? listJournalFiles();
|
|
181
|
+
if (!durationMs) {
|
|
182
|
+
return {
|
|
183
|
+
estimatedBytes: null,
|
|
184
|
+
detail: currentBytes == null ? `unable to parse --vacuum-time=${journalVacuumTime}` : `current journal usage ${formatBytes(currentBytes)}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (files.length === 0 && currentBytes > 0 && journalFiles == null) {
|
|
188
|
+
return {
|
|
189
|
+
estimatedBytes: null,
|
|
190
|
+
detail: `current journal usage ${formatBytes(currentBytes)}; journal files not readable`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const cutoff = new Date(now).getTime() - durationMs;
|
|
194
|
+
const estimatedBytes = files.filter(file => Number(file.mtimeMs) < cutoff).reduce((sum, file) => sum + (Number(file.size) || 0), 0);
|
|
195
|
+
const detail = currentBytes == null ? `journal files older than ${journalVacuumTime}` : `journal files older than ${journalVacuumTime}; current usage ${formatBytes(currentBytes)}`;
|
|
196
|
+
return { estimatedBytes, detail };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function estimateDockerBytes(execFn) {
|
|
200
|
+
const parsed = parseDockerSystemDf(execFn('docker', ['system', 'df']));
|
|
201
|
+
if (parsed.items.length === 0) return { estimatedBytes: null, detail: 'docker system df unavailable' };
|
|
202
|
+
const nonZero = parsed.items.filter(item => item.reclaimableBytes > 0);
|
|
203
|
+
const detail = nonZero.length === 0 ? 'docker system df reclaimable: 0B' : `docker system df reclaimable: ${nonZero.map(item => `${item.type} ${formatBytes(item.reclaimableBytes)}`).join(', ')}`;
|
|
204
|
+
return { estimatedBytes: parsed.totalReclaimableBytes, detail };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function estimateSystemCleanupCommand(item, options = {}) {
|
|
208
|
+
const execFn = options.execFn || (() => null);
|
|
209
|
+
const command = commandDisplay(item.argv);
|
|
210
|
+
|
|
211
|
+
if (item.action === 'apt-clean') {
|
|
212
|
+
return { ...item, command, estimatedBytes: measurePathBytes(APT_ARCHIVES_PATH, execFn), detail: APT_ARCHIVES_PATH };
|
|
213
|
+
}
|
|
214
|
+
if (item.action === 'apt-autoclean') {
|
|
215
|
+
return { ...item, command, estimatedBytes: 0, detail: 'already covered by apt-get clean' };
|
|
216
|
+
}
|
|
217
|
+
if (item.action === 'apt-autoremove') {
|
|
218
|
+
const estimatedBytes = parseAptAutoremoveFreedBytes(execFn('apt-get', ['-s', 'autoremove']));
|
|
219
|
+
return { ...item, command, estimatedBytes, detail: estimatedBytes == null ? 'apt-get -s autoremove unavailable' : 'apt-get -s autoremove' };
|
|
220
|
+
}
|
|
221
|
+
if (item.action === 'journal-vacuum') {
|
|
222
|
+
return { ...item, command, ...estimateJournalBytes({ ...options, journalVacuumTime: item.journalVacuumTime }) };
|
|
223
|
+
}
|
|
224
|
+
if (item.action === 'docker-prune') {
|
|
225
|
+
return { ...item, command, ...estimateDockerBytes(execFn) };
|
|
226
|
+
}
|
|
227
|
+
if (item.action === 'npm-cache-clean') {
|
|
228
|
+
const cachePath = execFn('npm', ['config', 'get', 'cache']);
|
|
229
|
+
const trimmedPath = cachePath ? cachePath.trim() : '';
|
|
230
|
+
const estimatedBytes = trimmedPath ? measurePathBytes(trimmedPath, execFn) : null;
|
|
231
|
+
return {
|
|
232
|
+
...item,
|
|
233
|
+
command,
|
|
234
|
+
estimatedBytes,
|
|
235
|
+
detail: trimmedPath || 'npm cache path unavailable',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
return { ...item, command, estimatedBytes: null, detail: 'estimate unavailable' };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function estimateSystemCleanupPlan(plan, options = {}) {
|
|
242
|
+
return plan.map(item => estimateSystemCleanupCommand(item, options));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function formatSystemCleanupEstimateLine(item) {
|
|
246
|
+
const estimate = item.estimatedBytes == null ? '?' : `~${formatBytes(item.estimatedBytes)}`;
|
|
247
|
+
const detail = item.detail ? ` (${item.detail})` : '';
|
|
248
|
+
return ` ${item.command.padEnd(34)} ${estimate.padStart(8)}${detail}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function formatSystemCleanupTotalLine(items) {
|
|
252
|
+
const knownItems = items.filter(item => item.estimatedBytes != null);
|
|
253
|
+
const total = knownItems.reduce((sum, item) => sum + item.estimatedBytes, 0);
|
|
254
|
+
const unknown = knownItems.length < items.length ? ' (known estimates only)' : '';
|
|
255
|
+
return ` estimated system reclaim: ~${formatBytes(total)}${unknown}`;
|
|
256
|
+
}
|