diff --git a/scripts/unit-smoke.mjs b/scripts/unit-smoke.mjs index 6015adc..0ed4428 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,46 @@ 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); +}); + +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/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」的 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');