path-resolver.js 11.6 KB
/**
 * DevLoop · pathResolver
 *
 * 所有 DevLoop 产物路径的唯一真源。其他服务(docStore / unitRegistry /
 * bugTracker / sseBroker / gitVault)都通过这里查路径,绝不硬编码
 * `data/companies/...`。
 *
 * 支持三种 mode(见 arch-project-root.html §②):
 *   - sandbox       : 自动生成 root_path = `{dataDir}/companies/{cid}/projects/{pid}/`
 *   - linked-new    : 用户指定 root_path(绝对路径),注册到 ~/.devloop/linked-projects.json
 *   - linked-existing : v2,本文件预留接口但 register 阶段拒收
 *
 * 详尽规格:docs/prd/newfunction/_spec/arch-project-root.md
 */

const fs = require('fs');
const path = require('path');
const os = require('os');

const DATA_DIR = path.resolve(__dirname, '../../../data');
const LINKED_REGISTRY = path.join(os.homedir(), '.devloop', 'linked-projects.json');

// 系统敏感路径黑名单(不允许作为 linked-new 的 target_path)
// 注意:os.tmpdir() (e.g. /var/folders/... on macOS, /tmp on Linux) 是允许的,
// 即使前缀匹配 /var;isSensitivePath 内部会先检查 tmpdir 例外。
const SENSITIVE_PREFIXES = [
  '/etc', '/usr', '/bin', '/sbin', '/boot',
  '/var/log', '/var/lib', '/var/db', '/var/spool', '/var/cache',
  '/System', '/Library/System', '/private/etc', '/private/var/log',
  path.join(os.homedir(), '.ssh'),
  path.join(os.homedir(), '.aws'),
  path.join(os.homedir(), '.gnupg'),
  path.join(os.homedir(), '.config'),
  path.join(os.homedir(), 'Downloads'),
];

const ALLOWED_EXEMPTIONS = [
  os.tmpdir(),       // /var/folders/... (mac) 或 /tmp (linux)
  '/tmp',
];

// 内存缓存:pid → { root_path, mode, company_id }
let cache = null;

function loadLinkedRegistry() {
  if (!fs.existsSync(LINKED_REGISTRY)) return {};
  try {
    return JSON.parse(fs.readFileSync(LINKED_REGISTRY, 'utf8'));
  } catch {
    return {};
  }
}

function saveLinkedRegistry(reg) {
  fs.mkdirSync(path.dirname(LINKED_REGISTRY), { recursive: true });
  fs.writeFileSync(LINKED_REGISTRY, JSON.stringify(reg, null, 2));
}

/** 全量扫描 + linked 注册表,构造内存索引。LoopController 启动时调一次。 */
function init() {
  cache = new Map();

  // 1. 扫 sandbox 项目(data/companies/*/projects/*/project.json)
  if (fs.existsSync(DATA_DIR)) {
    const companiesDir = path.join(DATA_DIR, 'companies');
    if (fs.existsSync(companiesDir)) {
      for (const cid of fs.readdirSync(companiesDir)) {
        const projectsDir = path.join(companiesDir, cid, 'projects');
        if (!fs.existsSync(projectsDir)) continue;
        for (const pid of fs.readdirSync(projectsDir)) {
          const root = path.join(projectsDir, pid);
          const meta = path.join(root, 'project.json');
          if (fs.existsSync(meta)) {
            cache.set(pid, { root_path: root, mode: 'sandbox', company_id: cid });
          }
        }
      }
    }
  }

  // 2. 叠加 linked 项目
  const linked = loadLinkedRegistry();
  for (const [pid, entry] of Object.entries(linked)) {
    cache.set(pid, {
      root_path: entry.root_path,
      mode: entry.mode,
      company_id: entry.company_id,
    });
  }

  return { count: cache.size };
}

function ensureInit() {
  if (cache === null) init();
}

function resolveProjectRoot(pid) {
  ensureInit();
  const entry = cache.get(pid);
  if (!entry) throw new Error(`Unknown project: ${pid}`);
  return entry.root_path;
}

function resolveProjectMeta(pid) {
  ensureInit();
  const entry = cache.get(pid);
  if (!entry) throw new Error(`Unknown project: ${pid}`);
  return { ...entry };
}

function findProjectByPath(abs) {
  ensureInit();
  for (const [pid, entry] of cache.entries()) {
    if (entry.root_path === abs) return { pid, ...entry };
  }
  return null;
}

// ── 派生路径 ──

function resolveUnitDir(pid, unitId) {
  return path.join(resolveProjectRoot(pid), 'units', unitId);
}

function resolvePrdSite(pid) {
  return path.join(resolveProjectRoot(pid), 'prd');
}

function resolveSddSite(pid) {
  return path.join(resolveProjectRoot(pid), 'sdd');
}

function resolveAgentState(pid, agent) {
  return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}.json`);
}

function resolveAgentTodolist(pid, agent) {
  return path.join(resolveProjectRoot(pid), 'agent-state', `${agent}-todo.json`);
}

function resolveAgentSnapshotDir(pid) {
  return path.join(resolveProjectRoot(pid), 'agent-state', 'snapshots');
}

function resolveTimelineLog(pid) {
  return path.join(resolveProjectRoot(pid), 'timeline.ndjson');
}

function resolveBugsLog(pid) {
  return path.join(resolveProjectRoot(pid), 'bugs.ndjson');
}

function resolveChangelog(pid) {
  return path.join(resolveProjectRoot(pid), 'changelog.ndjson');
}

function resolveStreamLog(pid) {
  return path.join(resolveProjectRoot(pid), 'stream.log');
}

function resolveHistoryDir(pid) {
  return path.join(resolveProjectRoot(pid), 'history');
}

function resolveIntegrationDir(pid) {
  return path.join(resolveProjectRoot(pid), 'integration');
}

function resolveUnitsJson(pid) {
  return path.join(resolveProjectRoot(pid), 'units.json');
}

function resolveProjectJson(pid) {
  return path.join(resolveProjectRoot(pid), 'project.json');
}

/** Memory 永远在 company 级(不跟项目根走,详见 arch-project-root §⑧) */
function resolveMemoryDir(companyId, agent) {
  return path.join(DATA_DIR, 'companies', companyId, 'memory', 'devloop', agent);
}

function resolveCompanyDir(companyId) {
  return path.join(DATA_DIR, 'companies', companyId);
}

// ── 创建项目 ──

function generatePid() {
  // p- + 6 位 base36
  const rand = Math.random().toString(36).slice(2, 8).padEnd(6, '0');
  return `p-${rand}`;
}

function isSensitivePath(abs) {
  const normalized = path.resolve(abs);
  // 1. 明确允许的目录(tmpdir 等)→ 即使前缀匹配也放行
  for (const ex of ALLOWED_EXEMPTIONS) {
    const e = path.resolve(ex);
    if (normalized === e || normalized.startsWith(e + path.sep)) return false;
  }
  // 2. 不允许直接放在根 /
  if (normalized === '/') return true;
  // 3. 检查黑名单前缀
  return SENSITIVE_PREFIXES.some(prefix => {
    const p = path.resolve(prefix);
    return normalized === p || normalized.startsWith(p + path.sep);
  });
}

/**
 * 在文件系统上分配一个 root_path,但不写任何业务文件。
 * 仅 mkdir + 校验路径合法。
 * 业务文件(project.json / .gitignore / git init)由 LoopController.createProject 完成。
 *
 * @returns { pid, root_path, mode }
 */
function allocateProjectRoot({ company_id, mode = 'sandbox', target_path }) {
  if (!company_id) throw new Error('company_id required');

  const pid = generatePid();

  let root_path;
  if (mode === 'sandbox') {
    root_path = path.join(DATA_DIR, 'companies', company_id, 'projects', pid);
    fs.mkdirSync(root_path, { recursive: true });
  } else if (mode === 'linked-new') {
    if (!target_path) throw new Error('target_path required for linked-new');
    if (!path.isAbsolute(target_path)) {
      throw new Error('target_path must be absolute');
    }
    if (isSensitivePath(target_path)) {
      const e = new Error(`sensitive_path: ${target_path} is in blacklist`);
      e.code = 'SENSITIVE_PATH';
      throw e;
    }
    // 父目录必须存在
    const parent = path.dirname(target_path);
    if (!fs.existsSync(parent)) {
      throw new Error(`parent dir does not exist: ${parent}`);
    }
    // 必须不存在或为空
    if (fs.existsSync(target_path)) {
      const entries = fs.readdirSync(target_path);
      if (entries.length > 0) {
        throw new Error(`target_path is not empty: ${target_path}`);
      }
    } else {
      fs.mkdirSync(target_path, { recursive: false });
    }
    root_path = path.resolve(target_path);
    // 注册到 linked-projects
    const reg = loadLinkedRegistry();
    reg[pid] = {
      root_path,
      mode: 'linked-new',
      company_id,
      registered_at: new Date().toISOString(),
    };
    saveLinkedRegistry(reg);
  } else if (mode === 'linked-existing') {
    const e = new Error('linked-existing mode is v2; not supported in v1');
    e.code = 'NOT_IMPLEMENTED';
    throw e;
  } else {
    throw new Error(`unknown mode: ${mode}`);
  }

  // 立即注入缓存
  ensureInit();
  cache.set(pid, { root_path, mode, company_id });

  return { pid, root_path, mode };
}

function unregisterLinkedProject(pid) {
  const reg = loadLinkedRegistry();
  if (reg[pid]) {
    delete reg[pid];
    saveLinkedRegistry(reg);
  }
  if (cache) cache.delete(pid);
}

/**
 * 列出全部项目。读 project.json 注入 name/created_at/status/archived/idea_summary。
 * 默认过滤已 archived 的项目;includeArchived=true 时全返。
 */
function listProjects({ includeArchived = false } = {}) {
  ensureInit();
  const out = [];
  for (const [pid, entry] of cache.entries()) {
    const meta = _readMetaSafe(entry.root_path);
    if (!includeArchived && meta && meta.archived) continue;
    out.push({
      pid,
      ...entry,
      name: meta?.name || meta?.idea_summary || pid,
      idea_summary: meta?.idea_summary || '',
      status: meta?.status || 'draft',
      created_at: meta?.created_at || null,
      archived: !!meta?.archived,
      archived_at: meta?.archived_at || null,
    });
  }
  // 倒序:最近创建的在前
  out.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
  return out;
}

function _readMetaSafe(rootPath) {
  try {
    const p = path.join(rootPath, 'project.json');
    if (!fs.existsSync(p)) return null;
    return JSON.parse(fs.readFileSync(p, 'utf8'));
  } catch { return null; }
}

/**
 * 修改 project.json 的字段(name / idea_summary / archived 等)。
 * 调用方负责传合法字段集;不做 FSM 校验(status 用 loop-controller._setProjectStatus)。
 */
function patchProjectMeta(pid, patch = {}) {
  ensureInit();
  const root = resolveProjectRoot(pid);
  const metaPath = path.join(root, 'project.json');
  if (!fs.existsSync(metaPath)) throw new Error(`project.json missing for ${pid}`);
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
  Object.assign(meta, patch);
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
  return meta;
}

/**
 * 硬删项目:sandbox 模式下递归删 root_path;linked 模式只解除注册(不删用户自有目录)。
 * 返回 { mode, removed_disk: boolean }
 */
function hardDeleteProject(pid) {
  ensureInit();
  const entry = cache.get(pid);
  if (!entry) throw new Error(`Unknown project: ${pid}`);
  let removed_disk = false;
  if (entry.mode === 'sandbox') {
    if (fs.existsSync(entry.root_path)) {
      fs.rmSync(entry.root_path, { recursive: true, force: true });
      removed_disk = true;
    }
  } else if (entry.mode === 'linked-new' || entry.mode === 'linked-existing') {
    // 只解注册,不动用户自己挑的目录
    const reg = loadLinkedRegistry();
    if (reg[pid]) { delete reg[pid]; saveLinkedRegistry(reg); }
  }
  cache.delete(pid);
  return { mode: entry.mode, removed_disk };
}

/** 测试用:重置缓存,强制下次重新扫描 */
function _resetForTesting() {
  cache = null;
}

module.exports = {
  init,
  resolveProjectRoot,
  resolveProjectMeta,
  findProjectByPath,
  resolveUnitDir,
  resolvePrdSite,
  resolveSddSite,
  resolveAgentState,
  resolveAgentTodolist,
  resolveAgentSnapshotDir,
  resolveTimelineLog,
  resolveBugsLog,
  resolveChangelog,
  resolveStreamLog,
  resolveHistoryDir,
  resolveIntegrationDir,
  resolveUnitsJson,
  resolveProjectJson,
  resolveMemoryDir,
  resolveCompanyDir,
  allocateProjectRoot,
  unregisterLinkedProject,
  listProjects,
  patchProjectMeta,
  hardDeleteProject,
  isSensitivePath,
  _resetForTesting,
  // 常量
  DATA_DIR,
  LINKED_REGISTRY,
  SENSITIVE_PREFIXES,
};