Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added (新增特性)

- **CLI 查询**:新增 `--version`/`-v` 与 `--help`/`-h`,无需等待 stdin。
- **分发 LICENSE**:本地复制与远端下载清单均包含仓库 `LICENSE`,修复分发物缺失 MIT 许可文本的问题。
- **远端清单漂移测试**:新增单测断言 `RUNTIME_FILES` 覆盖 `runtime/` 下全部 `.js/.json` 与相对 `require()` 依赖,并反向断言清单每一项在仓库中真实存在,防止重蹈 v0.3.9 漏 `update-checker.js` 的事故。
- **doctor 残留副本检查**:`--doctor` 新增 `codebuddy` 检查项,列出卸载时保留的 `.bak.codebuddy-hud.retained-*` 原文副本并提示手动清理(只列出、不自动删除)。

### Changed (变更)

Expand All @@ -32,6 +35,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Windows PATH**:安装/卸载保留 HKCU `Path` 的 `%VAR%` 与注册表值类型;卸载按归一化路径移除 runtime bin,避免误删或漏删。
- **effort 状态**:临时文件 + rename 原子写入,避免并发读到半截 JSON。
- **bootstrap 查询源**:兼容 `CODEBUDDY_HUD_REMOTE_PKG_URL` 旧名;显式 Release URL 跳过默认 302/API 查询,两变量同设时新名优先。
- **安装/卸载 JSONC 无损**:`--setup`/`--uninstall` 改为局部文本编辑,仅改动 `statusLine` 成员,保留注释、BOM、换行与格式;已有 `padding` 与额外字段予以保留;卸载在确认 `statusLine` 之外无改动时整文精确还原,否则仅回写该字段并把原文另存 `.retained-<time>-<pid>` 后再退役备份。
- **技能目录归属保护**:部署与卸载只处理指向本次源目录的链接或带 `.codebuddy-hud-managed` 标记的复制目录;未知同名目录/链接/文件一律警告并保留,不再误删用户文件。
- **主题配置保护**:`--theme` 对无效配置(非法 JSON、非对象根节点、不可读文件)直接报错并保留原文件,不再重置为空对象;写入复用原子写与权限保留,错误信息点出文件路径与严格 JSON 要求。`--setup --theme` 遇到换肤失败时中止,不继续写入 `statusLine`。
- **扫描预算上限**:`display.toolActivityTailBytes` 增加 `MAX_TOTAL_BYTES` 上限钳制,各逆向扫描按剩余字节预算限制单次读取;配置文件超过 256 KiB 或非普通文件时跳过该层,防止仓库内恶意配置触发大内存读取。

### Performance & Hardening (性能与稳健性加固)

Expand All @@ -44,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Tests (测试)

- 新增 CLI 参数组合(64 种)、TTY 主题帮助、transcript 增长/截断与 stat 失败、Release 查询源优先级等回归。全量验证命令见 [AGENTS.md](AGENTS.md#测试与验证),用例数以实际输出为准。
- 新增远端清单漂移(正/反向)、JSONC 字节级往返、技能归属、主题失败保护、doctor 残留副本回归;`verify-install` 扩展用户技能保护与主题失败场景校验。

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ codebuddy-hud --uninstall

用户主题配置(`codebuddy-hud.config.json`)与 `~/.codebuddy/codebuddy-hud-runtime/` 运行时目录会被保留(可手动删除);除 `statusLine` 外不会改动 `settings.json` 的其他配置项。

安装与卸载保留 `settings.json` 未涉及的注释、BOM 和格式。卸载后若文件与安装前备份不同,原文另存为旁边的 `.bak.codebuddy-hud.retained-<time>-<pid>`,路径会在卸载输出中显示。
安装与卸载保留 `settings.json` 未涉及的注释、BOM 和格式。卸载后若文件与安装前备份不同,原文另存为旁边的 `.bak.codebuddy-hud.retained-<time>-<pid>`,路径会在卸载输出中显示;也可用 `codebuddy-hud --doctor` 查看残留副本(只列出、不自动删除)。

---

Expand Down
2 changes: 1 addition & 1 deletion README_en.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ The uninstaller:

Your theme config (`codebuddy-hud.config.json`) and the installed runtime directory (`~/.codebuddy/codebuddy-hud-runtime/`) are preserved — delete them manually if desired. Nothing else in `settings.json` is touched.

Setup and uninstall preserve untouched comments, BOM and formatting in `settings.json`. If the restored file differs from the original backup, uninstall retains the original beside it as `.bak.codebuddy-hud.retained-<time>-<pid>` and prints its path.
Setup and uninstall preserve untouched comments, BOM and formatting in `settings.json`. If the restored file differs from the original backup, uninstall retains the original beside it as `.bak.codebuddy-hud.retained-<time>-<pid>` and prints its path. Run `codebuddy-hud --doctor` to list retained copies (listed only, never auto-deleted).

---

Expand Down
3 changes: 2 additions & 1 deletion docs/module-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,7 @@ export function printDoctorReport(report: DoctorReport, isJson?: boolean, option
5. `terminal / windowsPathNormalization`: Windows 路径大小写规范化检查。
6. `git`: Git PATH 可达性、当前仓库分支与探测延迟。
7. `transcript`: 遥测状态目录创建与可写性校验,并回显错误日志路径。
8. `codebuddy / retainedBackups`: 扫描 settings 同目录下的 `.bak.codebuddy-hud.retained-*` 原文副本并列出(最多 5 个),提示手动清理;只读不删,无残留时为 `ok`。

命令路径在 POSIX 下按原样与 shell 反转义两种形式校验;Windows 只按字面路径校验,保留 UNC 与 `\$`,避免把不存在的路径反转义成另一个有效文件。引号内尾反斜杠造成分词歧义时回退非转义分词。

Expand All @@ -629,7 +630,7 @@ export const THEMES: Array<{ name: string; label: string }>;

界面文案(列表标题、用法提示、选择器标题/快捷键提示、预览框标题)通过 `lang.js` 字典输出,`config.language` 生效;预览框顶边按显示宽度(CJK 双列)计算,与底边对齐。

`saveUserTheme()` 与配置加载保持严格 JSON 口径;仅文件不存在时新建。读取/解析失败或根节点不是对象时中止,原文件不变;成功写入复用 `atomicWriteSettingsFile()`,保留既有权限和软链接,新文件默认 `0600`。
`saveUserTheme()` 与配置加载保持严格 JSON 口径;仅文件不存在时新建。读取/解析失败或根节点不是对象时中止,原文件不变,错误信息点出配置文件路径与严格 JSON 要求;成功写入复用 `atomicWriteSettingsFile()`,保留既有权限和软链接,新文件默认 `0600`。

---

Expand Down
36 changes: 36 additions & 0 deletions runtime/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,41 @@ function checkPathNormalization(i18n, cwd) {
}];
}

// Uninstall retires the active `.bak.codebuddy-hud` snapshot but keeps a
// `.retained-<time>-<pid>` copy of the original text when it differs from the
// restored settings. Those copies accumulate by design (never auto-deleted),
// so doctor surfaces them for a manual review-and-delete decision.
function checkRetainedBackups(i18n) {
const settingsPath = getSettingsPath();
const dir = path.dirname(settingsPath);
const prefix = `${path.basename(settingsPath)}.bak.codebuddy-hud.retained-`;
let retained = [];
try {
retained = fs.readdirSync(dir).filter(name => name.startsWith(prefix));
} catch {
retained = [];
}
retained.sort();
if (retained.length === 0) {
return [{
category: 'codebuddy',
name: i18n.t('retainedBackups'),
status: 'ok',
detail: `No retained copies in ${dir}`,
message: null,
}];
}
const shown = retained.slice(0, 5).map(name => sanitizeTerminalText(name, 120));
const extra = retained.length - shown.length;
return [{
category: 'codebuddy',
name: i18n.t('retainedBackups'),
status: 'warn',
detail: extra > 0 ? `${shown.join(', ')} (and ${extra} more in ${dir})` : `${shown.join(', ')} (in ${dir})`,
message: i18n.t('doctorRetainedBackups'),
}];
}

function checkHostRefreshProtocol(i18n) {
return [{
category: 'codebuddy',
Expand All @@ -279,6 +314,7 @@ function runDoctor(options) {
const checks = [
checkNodeEnvironment(i18n),
...checkCodeBuddyConfig(i18n),
...checkRetainedBackups(i18n),
...checkHostRefreshProtocol(i18n),
...checkEncodingEnvironment(i18n, config),
...checkPathNormalization(i18n, cwd),
Expand Down
4 changes: 4 additions & 0 deletions runtime/lang.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const DICTIONARY = {
unicodeSupport: 'Unicode 渲染支持',
gitEnvironment: 'Git 环境与当前仓库',
transcriptAccess: 'Transcript 日志目录访问',
retainedBackups: '卸载残留的配置恢复副本',
hostRefreshProtocol: '宿主刷新架构契约',
doctorNodeOld: 'Node.js 版本过旧,需要 >= 18.0.0',
doctorSettingsMissing: '未找到 settings.json,请先运行 codebuddy-hud --setup',
Expand All @@ -38,6 +39,7 @@ const DICTIONARY = {
doctorCodepageWarn: '建议运行 "chcp 65001" 启用 UTF-8 支持以获得最佳渲染效果',
doctorGitBranchSlow: 'Git 分支查询耗时偏高',
doctorTranscriptMissing: '未检测到 transcript 日志目录或暂无写入',
doctorRetainedBackups: '卸载时保留了 settings 原文副本,确认无误后可手动删除',

// Uninstall & Setup
setupComplete: 'codebuddy-hud setup complete.',
Expand Down Expand Up @@ -81,6 +83,7 @@ const DICTIONARY = {
unicodeSupport: 'Unicode Support',
gitEnvironment: 'Git Environment & Active Repo',
transcriptAccess: 'Transcript Directory Access',
retainedBackups: 'Retained settings recovery copies',
hostRefreshProtocol: 'Host Refresh Architecture',
doctorNodeOld: 'Node.js version is too old, requires >= 18.0.0',
doctorSettingsMissing: 'settings.json not found, run codebuddy-hud --setup first',
Expand All @@ -89,6 +92,7 @@ const DICTIONARY = {
doctorCodepageWarn: 'Recommendation: run "chcp 65001" for optimal UTF-8 rendering',
doctorGitBranchSlow: 'Git branch resolution latency is high',
doctorTranscriptMissing: 'Transcript directory not found or empty',
doctorRetainedBackups: 'Uninstall left original-settings copies behind; delete them manually once confirmed unneeded',

// Uninstall & Setup
setupComplete: 'codebuddy-hud setup complete.',
Expand Down
11 changes: 9 additions & 2 deletions runtime/theme-selector.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,17 @@ function saveUserTheme(themeName) {
try {
userConfig = JSON.parse(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
if (!err || err.code === 'ENOENT') {
userConfig = {};
} else if (err instanceof SyntaxError) {
throw new Error(`Invalid HUD config at ${configPath}: ${err.message}. ` +
'HUD config must be strict JSON (no comments or trailing commas); the original file was left unchanged.');
} else {
throw err;
}
}
if (!isSettingsObject(userConfig)) {
throw new Error('HUD config root must be a JSON object');
throw new Error(`HUD config root must be a JSON object: ${configPath}`);
}
userConfig.theme = themeName;
atomicWriteSettingsFile(configPath, JSON.stringify(userConfig, null, 2));
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/bootstrap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ describe('bootstrap installer', () => {
const manifest = new Set(RUNTIME_FILES);
assert.equal(manifest.size, RUNTIME_FILES.length, 'manifest must not contain duplicate downloads');
assert.ok(manifest.has('LICENSE'));
for (const rel of RUNTIME_FILES) {
assert.ok(fs.existsSync(path.join(repoRoot, rel)), `manifest entry missing from repo: ${rel}`);
}
const visit = dir => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/doctor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,44 @@ describe('doctor diagnosis tool', () => {
});
});

test('reports ok when no retained settings backups exist', () => {
withIsolatedSettings(({ tmpDir }) => {
fs.writeFileSync(path.join(tmpDir, 'settings.json.bak.codebuddy-hud'), '{}');
const check = runDoctor({ cwd: process.cwd() }).checks
.find(c => c.category === 'codebuddy' && (c.name.includes('Retained') || c.name.includes('残留')));
assert.ok(check);
assert.equal(check.status, 'ok');
});
});

test('flags retained settings backups for manual review without auto-deleting', () => {
withIsolatedSettings(({ tmpDir }) => {
const names = [1, 2].map(n => `settings.json.bak.codebuddy-hud.retained-100${n}-9${n}`);
for (const name of names) fs.writeFileSync(path.join(tmpDir, name), '{}');
fs.writeFileSync(path.join(tmpDir, 'settings.json.bak.codebuddy-hud'), '{}');
const check = runDoctor({ cwd: process.cwd() }).checks
.find(c => c.category === 'codebuddy' && (c.name.includes('Retained') || c.name.includes('残留')));
assert.ok(check);
assert.equal(check.status, 'warn');
for (const name of names) assert.ok(check.detail.includes(name));
assert.ok(check.message && check.message.length > 0);
for (const name of names) assert.ok(fs.existsSync(path.join(tmpDir, name)), 'doctor must not delete retained copies');
});
});

test('caps the retained-backup listing instead of dumping every file', () => {
withIsolatedSettings(({ tmpDir }) => {
for (let n = 0; n < 7; n++) {
fs.writeFileSync(path.join(tmpDir, `settings.json.bak.codebuddy-hud.retained-200${n}-7${n}`), '{}');
}
const check = runDoctor({ cwd: process.cwd() }).checks
.find(c => c.category === 'codebuddy' && (c.name.includes('Retained') || c.name.includes('残留')));
assert.ok(check);
assert.equal(check.status, 'warn');
assert.ok(check.detail.includes('and 2 more'));
});
});

test('flags a Windows shim whose forwarded script is missing', () => {
withIsolatedSettings(({ tmpDir, writeSettings }) => {
const shim = path.join(tmpDir, 'bin', 'codebuddy-hud.cmd');
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/theme-selector.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,30 @@ describe('theme-selector', () => {
}
});

it('reports invalid user config with the file path and strict-JSON hint', () => {
const configPath = path.join(process.env.CODEBUDDY_HOME, 'codebuddy-hud.config.json');
try {
for (const raw of ['{"display":{"showCost":false},}', '']) {
fs.writeFileSync(configPath, raw);
assert.throws(() => saveUserTheme('amber'), err => {
assert.ok(err.message.includes(configPath), 'error must name the config file');
assert.ok(err.message.includes('strict JSON'), 'error must explain the strict-JSON rule');
assert.ok(err.message.includes('left unchanged'), 'error must confirm the file was preserved');
return true;
});
assert.equal(fs.readFileSync(configPath, 'utf8'), raw);
}
fs.writeFileSync(configPath, '[]');
assert.throws(() => saveUserTheme('amber'), err => {
assert.ok(err.message.includes('must be a JSON object'));
assert.ok(err.message.includes(configPath));
return true;
});
} finally {
fs.rmSync(configPath, { force: true });
}
});

it('preserves other options and refuses writes through multiple hard links', () => {
const configPath = path.join(process.env.CODEBUDDY_HOME, 'codebuddy-hud.config.json');
const linkedPath = path.join(tmpDir, 'theme-hardlink.json');
Expand Down
Loading