From 42c62be4e64c05fc75acd805c1937b6c3d807fe9 Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 02:13:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(issue-13):=20homedir()/.dsh=20=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E2=80=94=E2=80=94=E4=BC=98=E5=85=88=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=20DSH=5FHOME=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=20(closes=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/unit-smoke.mjs | 20 +++++++++++++++++++- src/config.js | 27 +++++++++++++++++++++++++++ src/credentials.js | 5 +++-- src/model-provider.js | 5 +++-- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/scripts/unit-smoke.mjs b/scripts/unit-smoke.mjs index 6015adc..a2bef73 100644 --- a/scripts/unit-smoke.mjs +++ b/scripts/unit-smoke.mjs @@ -14,6 +14,7 @@ * 零敏感:全部用通用样例,不触碰任何真实仓库与配置。 */ import { join } from 'node:path'; +import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import assert from 'node:assert/strict'; @@ -21,7 +22,7 @@ const ROOT = join(fileURLToPath(import.meta.url), '..', '..'); const { buildReviewArgs, runEngine, prepareEngineEnv, collectPendingPaths } = await import(new URL('../src/runner.js', import.meta.url).href); const { isSensitivePath, listSensitiveFiles } = await import(new URL('../src/sensitive-list.js', import.meta.url).href); const { reportFilePath, repoNameOf } = await import(new URL('../src/report.js', import.meta.url).href); -const { normalizeConfig, DEFAULT_CONFIG } = await import(new URL('../src/config.js', import.meta.url).href); +const { normalizeConfig, DEFAULT_CONFIG, resolveDshRoot } = await import(new URL('../src/config.js', import.meta.url).href); const { createSettingsSchema } = await import(new URL('../src/settings-schema.js', import.meta.url).href); const { createModelSource, SESSION_MODEL, DEEPSEEK_OFFICIAL_DEFAULT } = await import(new URL('../src/model-provider.js', import.meta.url).href); const { parseCredentialsText } = await import(new URL('../src/credentials.js', import.meta.url).href); @@ -198,6 +199,23 @@ t('normalizeConfig:并发开关类型清洗 + 广度钳制 1..32', () => { assert.equal(normalizeConfig({ concurrency: 'x' }).concurrency, 8); // 非数 → 默认 }); +t('resolveDshRoot:优先 DSH_HOME,未设置回退 homedir()/.dsh(issue #13)', () => { + // 注:用例全走 env 注入,不碰进程全局 process.env(不影响其他用例)。 + assert.equal(resolveDshRoot({ DSH_HOME: 'D:/dsh-home' }), 'D:/dsh-home'); + // trim 后采用(DSH_HOME 值带首尾空白时去空白,Windows 反斜杠尾分隔符保留)。 + assert.equal(resolveDshRoot({ DSH_HOME: ' D:\\dsh-home\\ ' }), 'D:\\dsh-home\\'); + // 未设置 / 空串 / 纯空白 → 一律回退默认 ~/.dsh。 + assert.equal(resolveDshRoot({}), join(homedir(), '.dsh')); + assert.equal(resolveDshRoot({ DSH_HOME: '' }), join(homedir(), '.dsh')); + assert.equal(resolveDshRoot({ DSH_HOME: ' ' }), join(homedir(), '.dsh')); + // 不传参 = 默认走进程真实 env:按解析口径动态自洽(进程若设了 DSH_HOME 也一致)。 + const expectDefault = + process.env.DSH_HOME && process.env.DSH_HOME.trim() !== '' + ? process.env.DSH_HOME.trim() + : join(homedir(), '.dsh'); + assert.equal(resolveDshRoot(), expectDefault); +}); + /* ---------- 5. runEngine ---------- */ t('runEngine:stdout 收集 + 退出码', async () => { const r = await runEngine({ bin: process.execPath, args: ['-e', 'console.log("hello")'], timeoutMs: 5000 }); diff --git a/src/config.js b/src/config.js index 1e75880..938100b 100644 --- a/src/config.js +++ b/src/config.js @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 dsh-code-review contributors +import { join } from 'node:path'; +import { homedir } from 'node:os'; + /** * config.js —— dsh-code-review 配置模型:默认值、归一化、宿主 volatile 解包。 * @@ -160,3 +163,27 @@ export function normalizeConfig(raw) { } return cfg; } + +/** + * 解析 DSH 根目录(issue #13:homedir()/.dsh 硬编码——自定义 DSH_HOME 时 + * 路由/凭据解析失败)。 + * + * 口径: + * - 优先 env.DSH_HOME:非空字符串才采用,trim 后作为根(防值带意外空白; + * 空串/纯空白/非字符串一律视为未设置)——Windows 用户示例 D:\dsh-home; + * - 未设置/空 → 回退 homedir()/.dsh(默认安装布局,行为与旧版完全一致)。 + * + * 所有「home 下 .dsh 目录」相关的路径来源(模型路由 profiles、凭据文件等) + * 必须统一走这里,避免自定义 DSH_HOME 布局下各处解析不一致。 + * + * @param {object} [env] 环境变量对象(默认 process.env;单测经此注入, + * 不污染进程全局环境)。 + * @returns {string} DSH 根目录绝对路径。 + */ +export function resolveDshRoot(env = process.env) { + const home = + env && typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== '' + ? env.DSH_HOME.trim() + : ''; + return home !== '' ? home : join(homedir(), '.dsh'); +} diff --git a/src/credentials.js b/src/credentials.js index 1093c5b..f0a99d8 100644 --- a/src/credentials.js +++ b/src/credentials.js @@ -26,7 +26,7 @@ */ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { resolveDshRoot } from './config.js'; /** 从凭据文本中提取指定引用名的值。 * 实况(实证 DSH 凭据 yaml):所有凭据引用在 @@ -62,7 +62,8 @@ function escapeRe(s) { * @returns {string|null} 值 / ''(显式空值)/ null(文件缺失、键不存在或读取失败)。 */ function readCredentialsYaml(name) { try { - const file = join(homedir(), '.dsh', '.credentials.yaml'); + // 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。 + const file = join(resolveDshRoot(), '.credentials.yaml'); if (!existsSync(file)) return null; return parseCredentialsText(readFileSync(file, 'utf8'), name); } catch (error) { diff --git a/src/model-provider.js b/src/model-provider.js index 0eb5402..ff0692c 100644 --- a/src/model-provider.js +++ b/src/model-provider.js @@ -30,8 +30,8 @@ */ import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; import { createRequire } from 'node:module'; +import { resolveDshRoot } from './config.js'; /** 评审模型「沿用会话模型」的保留标识(与 settings-schema / config 一致)。 */ export const SESSION_MODEL = 'session'; @@ -67,7 +67,8 @@ function loadProfileLlmConfig() { } catch { return null; // 开发环境若 junction 未建也能继续(路由表可能为空,resolveProvider 返回 null) } - const profilesRoot = join(homedir(), '.dsh', 'profiles'); + // 根目录统一走 resolveDshRoot:优先 DSH_HOME,回退 ~/.dsh(issue #13)。 + const profilesRoot = join(resolveDshRoot(), 'profiles'); // readdirSync 包 try/catch——目录无读权限(EACCES)或 // existsSync 后被删的竞态不再一路抛到调用方(文件头约定「任何一步失败都 // 不抛异常」);目录名排序使多 profile 时「取第一个 deepseek/alternate」的 From 3a48dc82d99e805f02bf29eeb046f43d34da11d3 Mon Sep 17 00:00:00 2001 From: Nicholas023 Date: Tue, 20 Oct 2026 02:29:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(issue-13):=20task-book=20=E7=AD=89?= =?UTF-8?q?=E5=89=A9=E4=BD=99=20.dsh=20=E7=A1=AC=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=20resolveDshRoot=20(closes=20#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/unit-smoke.mjs | 23 +++++++++++++++++++++++ src/task-book.js | 9 +++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/scripts/unit-smoke.mjs b/scripts/unit-smoke.mjs index a2bef73..0ed4428 100644 --- a/scripts/unit-smoke.mjs +++ b/scripts/unit-smoke.mjs @@ -216,6 +216,29 @@ t('resolveDshRoot:优先 DSH_HOME,未设置回退 homedir()/.dsh(issue #13 assert.equal(resolveDshRoot(), expectDefault); }); +t('task-book:REVIEW_HOME 回退分支跟随 DSH_HOME,未设置回退 ~/.dsh/dsh-code-review(issue #13)', async () => { + // REVIEW_HOME 是模块顶层常量、import 时求值:用带 query 的独立 URL 重新求值 + // 模块实例(不同 URL = 不命中既有实例缓存),config.js 的 resolveDshRoot 是 + // 函数引用、默认参数调用时才读 process.env,故能观察到当前环境。 + const prevReview = process.env.DSH_REVIEW_HOME; + const prevDsh = process.env.DSH_HOME; + try { + delete process.env.DSH_REVIEW_HOME; // 只测回退分支,DSH_REVIEW_HOME 优先逻辑由既有用例覆盖 + // 场景 1:设置自定义 DSH_HOME → REVIEW_HOME = /dsh-code-review + process.env.DSH_HOME = 'D:/dsh-home'; + const tb1 = await import(new URL('../src/task-book.js?case=dshhome', import.meta.url).href); + assert.equal(tb1.REVIEW_HOME, join('D:/dsh-home', 'dsh-code-review')); + // 场景 2:未设置 DSH_HOME → 回退 homedir()/.dsh/dsh-code-review(默认布局) + delete process.env.DSH_HOME; + const tb2 = await import(new URL('../src/task-book.js?case=fallback', import.meta.url).href); + assert.equal(tb2.REVIEW_HOME, join(homedir(), '.dsh', 'dsh-code-review')); + } finally { + process.env.DSH_REVIEW_HOME = prevReview; + if (prevDsh === undefined) delete process.env.DSH_HOME; + else process.env.DSH_HOME = prevDsh; + } +}); + /* ---------- 5. runEngine ---------- */ t('runEngine:stdout 收集 + 退出码', async () => { const r = await runEngine({ bin: process.execPath, args: ['-e', 'console.log("hello")'], timeoutMs: 5000 }); diff --git a/src/task-book.js b/src/task-book.js index 570bd95..c2ffafb 100644 --- a/src/task-book.js +++ b/src/task-book.js @@ -22,16 +22,17 @@ */ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync, openSync, closeSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { homedir } from 'node:os'; import { spawn } from 'node:child_process'; import { randomInt } from 'node:crypto'; import { fileURLToPath } from 'node:url'; -import { DEFAULT_CONFIG } from './config.js'; // 仅取键集合作 config 白名单(不构成循环依赖:config.js 无本地导入) +import { DEFAULT_CONFIG, resolveDshRoot } from './config.js'; // 键集白名单 + DSH 根解析(不构成循环依赖:config.js 无本地导入) -/** 评审工作根目录(env 可覆盖,测试/多用户场景用)。 */ +/** 评审工作根目录(env 可覆盖,测试/多用户场景用)。 + * 回退分支跟随 resolveDshRoot()(优先 DSH_HOME,未设置回退 ~/.dsh)—— + * 保证 DSH_HOME 自定义布局下任务簿/凭据/路由根一致,不出现 split-brain(issue #13)。 */ export const REVIEW_HOME = process.env.DSH_REVIEW_HOME ? resolve(process.env.DSH_REVIEW_HOME) - : join(homedir(), '.dsh', 'dsh-code-review'); + : join(resolveDshRoot(), 'dsh-code-review'); /** 任务目录。 */ export const TASKS_ROOT = join(REVIEW_HOME, 'tasks');