/**
* 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;
}
}