BID · Console
Baseline · Intelligence · Decision
web/code-tree.ts 3,721 bytes · typescript
/**
 * Code-browser file tree.
 *
 * Walks a small allow-list of directories (src/, scripts/, web/) plus
 * root-level config files and returns a tree the UI can render.
 *
 * Path safety: every file fetch goes back through readCodeFile which
 * resolves against the allowed roots only — paths that escape via
 * symlinks or `..` are rejected.
 */

import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';

const ALLOWED_DIRS = ['src', 'scripts', 'web'];
const ALLOWED_ROOT_FILES = ['package.json', 'tsconfig.json', 'README.md'];
const ALLOWED_EXT = new Set([
  '.ts', '.tsx', '.js', '.mjs', '.json', '.yaml', '.yml', '.md', '.css',
]);
const HIDE_DIRS = new Set(['node_modules', 'dist', '.git', 'output', '.cache']);

export interface TreeNode {
  readonly kind: 'dir' | 'file';
  readonly name: string;
  readonly path: string;
  readonly children?: readonly TreeNode[];
  readonly bytes?: number;
}

async function walkDir(root: string, rel: string): Promise<TreeNode[]> {
  const full = path.join(root, rel);
  let entries;
  try { entries = await readdir(full, { withFileTypes: true }); }
  catch { return []; }
  const dirs: TreeNode[] = [];
  const files: TreeNode[] = [];
  for (const e of entries) {
    if (e.name.startsWith('.')) continue;
    if (HIDE_DIRS.has(e.name)) continue;
    const childRel = path.posix.join(rel.split(path.sep).join('/'), e.name);
    if (e.isDirectory()) {
      const children = await walkDir(root, childRel);
      if (children.length > 0) {
        dirs.push({ kind: 'dir', name: e.name, path: childRel, children });
      }
    } else if (e.isFile() && ALLOWED_EXT.has(path.extname(e.name).toLowerCase())) {
      try {
        const s = await stat(path.join(full, e.name));
        files.push({ kind: 'file', name: e.name, path: childRel, bytes: s.size });
      } catch { /* skip */ }
    }
  }
  dirs.sort((a, b) => a.name.localeCompare(b.name));
  files.sort((a, b) => a.name.localeCompare(b.name));
  return [...dirs, ...files];
}

export async function buildCodeTree(root: string): Promise<TreeNode[]> {
  const top: TreeNode[] = [];
  for (const d of ALLOWED_DIRS) {
    const children = await walkDir(root, d);
    if (children.length > 0) top.push({ kind: 'dir', name: d, path: d, children });
  }
  for (const f of ALLOWED_ROOT_FILES) {
    try {
      const s = await stat(path.join(root, f));
      if (s.isFile()) top.push({ kind: 'file', name: f, path: f, bytes: s.size });
    } catch { /* skip */ }
  }
  return top;
}

const EXT_LANG: Record<string, string> = {
  '.ts': 'typescript',
  '.tsx': 'typescript',
  '.js': 'javascript',
  '.mjs': 'javascript',
  '.json': 'json',
  '.yaml': 'yaml',
  '.yml': 'yaml',
  '.md': 'markdown',
  '.css': 'css',
};

export interface CodeFile {
  readonly path: string;
  readonly content: string;
  readonly language: string;
  readonly bytes: number;
}

function isAllowedPath(rel: string): boolean {
  if (rel.includes('..') || path.isAbsolute(rel)) return false;
  const norm = rel.split(path.sep).join('/');
  if (ALLOWED_ROOT_FILES.includes(norm)) return true;
  return ALLOWED_DIRS.some(d => norm === d || norm.startsWith(`${d}/`));
}

export async function readCodeFile(root: string, rel: string): Promise<CodeFile | null> {
  if (!isAllowedPath(rel)) return null;
  const ext = path.extname(rel).toLowerCase();
  if (!ALLOWED_EXT.has(ext)) return null;
  const full = path.resolve(root, rel);
  if (!full.startsWith(path.resolve(root))) return null;
  try {
    const [statRes, body] = await Promise.all([stat(full), readFile(full, 'utf8')]);
    return { path: rel, content: body, language: EXT_LANG[ext] ?? 'plaintext', bytes: statRes.size };
  } catch {
    return null;
  }
}