path-resolver.js 9.19 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);
}

function listProjects() {
  ensureInit();
  return Array.from(cache.entries()).map(([pid, entry]) => ({ pid, ...entry }));
}

/** 测试用:重置缓存,强制下次重新扫描 */
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,
  isSensitivePath,
  _resetForTesting,
  // 常量
  DATA_DIR,
  LINKED_REGISTRY,
  SENSITIVE_PREFIXES,
};