- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
94 lines
5.2 KiB
JavaScript
94 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* ws-cleanup.cjs —— 用户工作区(ws)定期清理 + 磁盘画像(档案 28)
|
||
*
|
||
* 分三档(**判定依据只有"路径/文件名模式 + 年龄"**,因为无法可靠区分"AI 写的"):
|
||
* T1 明确垃圾/平台产物 → 直接清(白名单精确匹配)
|
||
* T2 临时脚本(一次性、无复用价值)→ 超过 N 天未修改才清(默认 90 天)
|
||
* T3 其余一切(文档/表格/图片/视频/数据/目录)→ **永不自动删**,只统计
|
||
*
|
||
* 安全:默认 dry-run;--apply 时一律 `mv` 到 <userRoot>/trash/<日期>-ws-cleanup/(保留 30 天可恢复);
|
||
* 仅当该用户 ws ≥ --threshold MB 才动手(默认 2048 MB)。
|
||
*
|
||
* 用法:
|
||
* node ws-cleanup.cjs # dry-run + 画像
|
||
* node ws-cleanup.cjs --apply # 执行(含阈值判断)
|
||
* node ws-cleanup.cjs --apply --threshold 1024 --days 60
|
||
* node ws-cleanup.cjs --user-id <uuid>
|
||
*/
|
||
const { execFileSync } = require('node:child_process')
|
||
const { existsSync, mkdirSync, readdirSync, statSync } = require('node:fs')
|
||
const { join, extname, basename } = require('node:path')
|
||
const Database = require('/opt/dshs/node_modules/better-sqlite3')
|
||
|
||
const argv = process.argv.slice(2)
|
||
const APPLY = argv.includes('--apply')
|
||
const num = (flag, dflt) => { const i = argv.indexOf(flag); const v = i >= 0 ? Number(argv[i + 1]) : NaN; return Number.isFinite(v) ? v : dflt }
|
||
const THRESHOLD_MB = num('--threshold', 2048)
|
||
const DAYS = num('--days', 90)
|
||
const idx = argv.indexOf('--user-id')
|
||
const ONLY = idx >= 0 ? argv[idx + 1] : ''
|
||
const STAMP = new Date().toISOString().slice(0, 10)
|
||
const CUTOFF = Date.now() - DAYS * 86400_000
|
||
|
||
// T1:平台产物/明确垃圾(精确名或后缀);T2:一次性脚本(仅"顶层脚本文件"才算,避免误伤项目目录里的源码)
|
||
const T1_DIRS = ['.local', '.cache', '.poc-backup', 'poc', '__pycache__', '.pytest_cache', 'node_modules', '.ipynb_checkpoints']
|
||
const T1_SUFFIX = ['.tgz', '.tmp', '.log', '.bak', '.part', '.crdownload']
|
||
const T2_EXT = new Set(['.py', '.js', '.mjs', '.cjs', '.sh', '.bash', '.ps1', '.psm1', '.bat', '.cmd', '.ts', '.ipynb', '.sql'])
|
||
|
||
const duKB = (p) => { try { return Number(execFileSync('du', ['-sk', p], { encoding: 'utf8' }).split(/\s+/)[0]) * 1024 } catch { return 0 } }
|
||
const fmt = (b) => (b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' : b >= 1048576 ? (b / 1048576).toFixed(1) + ' MB' : (b / 1024).toFixed(0) + ' KB')
|
||
|
||
const db = new Database('/var/lib/dshs/dshs.db', { readonly: true })
|
||
const users = db.prepare('SELECT id, username, uid, home_dir FROM users').all().filter((u) => ONLY === '' || u.id === ONLY)
|
||
|
||
for (const u of users) {
|
||
const ws = join(u.home_dir, '..', 'ws')
|
||
if (!existsSync(ws)) { console.log(` ${u.username}: NO_WS`); continue }
|
||
const wsBytes = duKB(ws)
|
||
const t1 = [], t2 = [] // T2 可被 ws/.keep 豁免
|
||
let otherBytes = 0, otherCount = 0
|
||
|
||
for (const entry of readdirSync(ws)) {
|
||
const p = join(ws, entry)
|
||
let st
|
||
try { st = statSync(p) } catch { continue }
|
||
if (st.isDirectory()) {
|
||
if (T1_DIRS.includes(entry)) { t1.push({ p, size: duKB(p) }); continue }
|
||
otherBytes += duKB(p); otherCount += 1 // 目录一律算用户资产(T3)
|
||
continue
|
||
}
|
||
const ext = extname(entry).toLowerCase()
|
||
if (T1_SUFFIX.includes(ext)) { t1.push({ p, size: st.size }); continue }
|
||
if (T2_EXT.has(ext) && st.mtimeMs < CUTOFF) { t2.push({ p, size: st.size, mtime: st.mtime }); continue }
|
||
otherBytes += st.size; otherCount += 1
|
||
}
|
||
|
||
// .keep 豁免(档案 28):ws 顶层放一个 .keep 文件 → 该用户**跳过 T2**(一次性脚本不清理),T1 仍清
|
||
const keepAll = existsSync(join(ws, '.keep'))
|
||
if (keepAll && t2.length > 0) {
|
||
console.log(` (.keep 已存在 → T2 保留 ${t2.length} 项,不清理)`)
|
||
t2.length = 0
|
||
}
|
||
const t1B = t1.reduce((a, c) => a + c.size, 0), t2B = t2.reduce((a, c) => a + c.size, 0)
|
||
const over = wsBytes >= THRESHOLD_MB * 1048576
|
||
console.log(` ${u.username}: ws=${fmt(wsBytes)} (${over ? '≥' : '<'} 阈值 ${THRESHOLD_MB}MB) | T1=${t1.length}项/${fmt(t1B)} | T2=${t2.length}项(>${DAYS}天)/${fmt(t2B)} | 资产=${otherCount}项/${fmt(otherBytes)}`)
|
||
for (const c of t1) console.log(` T1 ${basename(c.p)} ${fmt(c.size)}`)
|
||
for (const c of t2) console.log(` T2 ${basename(c.p)} ${fmt(c.size)} (mtime ${c.mtime.toISOString().slice(0, 10)})`)
|
||
|
||
if (APPLY && over && (t1.length + t2.length) > 0) {
|
||
const trash = join(u.home_dir, '..', 'trash', `${STAMP}-ws-cleanup`)
|
||
mkdirSync(trash, { recursive: true })
|
||
for (const c of [...t1, ...t2]) {
|
||
const dest = join(trash, c.p.slice(ws.length + 1).replace(/\//g, '__'))
|
||
try { execFileSync('mv', [c.p, dest]); execFileSync('chown', ['-R', `${u.uid}:${u.uid}`, dest]) }
|
||
catch (e) { console.log(` ! 失败 ${c.p}: ${String(e.message).split('\n')[0]}`) }
|
||
}
|
||
console.log(` → 已清 ${t1.length + t2.length} 项 / ${fmt(t1B + t2B)} → trash/${STAMP}-ws-cleanup(保留 30 天)`)
|
||
} else if (APPLY && !over) {
|
||
console.log(` (未超阈值,跳过清理)`)
|
||
}
|
||
}
|
||
db.close()
|
||
console.log(APPLY ? 'done(已按阈值执行)' : 'done(dry-run,未改动)')
|