/** * lib/fs.ts — File system helpers for all SmartStack Studio dev CLIs. * Ported from SmartStack.cli/src/mcp/utils/fs.ts (legacy V1). * * All functions are async and throw typed FileSystemError on failure. * findFiles / findDirectories use glob with sensible defaults (node_modules/bin/obj excluded). */ import { stat, mkdir, readFile, writeFile, cp, rm, readdir, rename } from 'node:fs/promises'; import { existsSync, readdirSync } from 'node:fs'; import path from 'node:path'; import { minimatch } from 'minimatch'; export class FileSystemError extends Error { constructor( message: string, public readonly operation: string, public readonly path: string, public readonly cause?: Error, ) { super(message); this.name = 'FileSystemError'; } } /** Throw if targetPath escapes baseDir (path traversal protection). */ export function validatePathSecurity(targetPath: string, baseDir: string): void { const normalizedTarget = path.resolve(targetPath); const normalizedBase = path.resolve(baseDir); if ( !normalizedTarget.startsWith(normalizedBase + path.sep) && normalizedTarget !== normalizedBase ) { throw new FileSystemError( `Path traversal detected: "${targetPath}" is outside allowed directory "${baseDir}"`, 'validatePathSecurity', targetPath, ); } } export function safeJoinPath(baseDir: string, ...segments: string[]): string { const joined = path.join(baseDir, ...segments); validatePathSecurity(joined, baseDir); return joined; } /** * Fail-closed guard for FRONTEND scaffolds. Assert `baseDir` is a React web app * root (has a `package.json`) and is NOT a .NET backend / repo root. * * Every frontend scaffold writes to `/src/…`. If `baseDir` is wrongly * the repository root, those files land in the BACKEND's `src/` (Domain/Api/…) — * which is exactly what triggered the ba-develop incident where a subagent then * `rm -rf`'d the colliding `src/`. Refuse early instead of writing to the wrong * tree. Resolve the correct directory with detector.ts `findWebProjectFolder()`. */ export function assertWebProjectRoot(baseDir: string): void { const base = path.resolve(baseDir); let entries: string[]; try { entries = readdirSync(base); } catch { throw new FileSystemError( `projectPath "${baseDir}" does not exist or is unreadable — expected a React web app directory (e.g. web/).`, 'assertWebProjectRoot', baseDir, ); } // A .NET solution at the root → this is a repo/backend root, never a web app. if (entries.some((e) => e.toLowerCase().endsWith('.sln'))) { throw new FileSystemError( `projectPath "${baseDir}" looks like a .NET solution / repo root (found a .sln file). ` + `Frontend scaffolds write to /src and would overwrite the backend's src/. ` + `Pass the web app directory (e.g. web/) — resolve it with findWebProjectFolder().`, 'assertWebProjectRoot', baseDir, ); } // A src/ holding .NET projects (.csproj or *.Domain/.Application/.Infrastructure/.Api) // is the backend src — refuse so we never write frontend files on top of it. const srcDir = path.join(base, 'src'); if (existsSync(srcDir)) { let srcEntries: string[] = []; try { srcEntries = readdirSync(srcDir); } catch { /* unreadable — fall through to the package.json check */ } const backendRe = /\.csproj$|\.(Domain|Application|Infrastructure|Api)$/i; const offenders = srcEntries.filter((e) => backendRe.test(e)); if (offenders.length > 0) { throw new FileSystemError( `projectPath "${baseDir}" looks like a .NET backend root: its src/ contains backend projects (${offenders.join(', ')}). ` + `Frontend scaffolds write to /src and would overwrite the backend. ` + `Pass the web app directory (e.g. web/) — resolve it with findWebProjectFolder().`, 'assertWebProjectRoot', baseDir, ); } } // Positive web marker. if (!existsSync(path.join(base, 'package.json'))) { throw new FileSystemError( `projectPath "${baseDir}" is not a React web app root (no package.json). ` + `Frontend scaffolds must target the web app directory (e.g. web/) — resolve it with findWebProjectFolder().`, 'assertWebProjectRoot', baseDir, ); } } export async function fileExists(filePath: string): Promise { try { const s = await stat(filePath); return s.isFile(); } catch { return false; } } export async function directoryExists(dirPath: string): Promise { try { const s = await stat(dirPath); return s.isDirectory(); } catch { return false; } } export async function ensureDirectory(dirPath: string): Promise { await mkdir(dirPath, { recursive: true }); } export async function readJson(filePath: string): Promise { try { const content = await readFile(filePath, 'utf-8'); try { return JSON.parse(content) as T; } catch (parseError) { throw new FileSystemError( `Invalid JSON in file: ${filePath}`, 'readJson', filePath, parseError instanceof Error ? parseError : undefined, ); } } catch (error) { if (error instanceof FileSystemError) throw error; const err = error instanceof Error ? error : new Error(String(error)); throw new FileSystemError( `Failed to read JSON file: ${filePath} - ${err.message}`, 'readJson', filePath, err, ); } } export async function writeJson(filePath: string, data: T): Promise { try { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); throw new FileSystemError( `Failed to write JSON file: ${filePath} - ${err.message}`, 'writeJson', filePath, err, ); } } export async function readText(filePath: string): Promise { try { return await readFile(filePath, 'utf-8'); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); throw new FileSystemError( `Failed to read file: ${filePath} - ${err.message}`, 'readText', filePath, err, ); } } export async function writeText(filePath: string, content: string): Promise { try { await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, content, 'utf-8'); } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); throw new FileSystemError( `Failed to write file: ${filePath} - ${err.message}`, 'writeText', filePath, err, ); } } /** * ATOMIC text write: the content lands in a temp file IN THE SAME DIRECTORY * (a cross-volume rename fails on Windows) and is `rename`d over the target — * a reader never sees a half-written file, a crash never leaves a truncated * one. The temp name is pid-suffixed so concurrent writers of DIFFERENT * targets never collide; on failure the temp file is removed. */ export async function writeFileAtomic(filePath: string, content: string): Promise { const dir = path.dirname(filePath); const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.tmp`); try { await mkdir(dir, { recursive: true }); await writeFile(tmp, content, 'utf-8'); await rename(tmp, filePath); } catch (error) { await rm(tmp, { force: true }).catch(() => {}); const err = error instanceof Error ? error : new Error(String(error)); throw new FileSystemError( `Failed to atomically write file: ${filePath} - ${err.message}`, 'writeFileAtomic', filePath, err, ); } } export async function copyFile(src: string, dest: string): Promise { await mkdir(path.dirname(dest), { recursive: true }); await cp(src, dest, { recursive: true }); } export async function removeFile(filePath: string): Promise { await rm(filePath, { force: true }); } export async function removeDirectory(dirPath: string): Promise { await rm(dirPath, { recursive: true, force: true }); } const DEFAULT_IGNORE = [ '**/node_modules/**', '**/bin/**', '**/obj/**', '**/dist/**', '**/out/**', '**/.git/**', ]; /** * Recursively walk a directory and yield every entry (files AND subdirs) as * absolute paths. Skips any entry whose relative-to-cwd path matches the * ignorePatterns (minimatch). */ async function walk( cwd: string, options: { includeDirs: boolean; ignorePatterns: string[] }, ): Promise { const results: string[] = []; const { includeDirs, ignorePatterns } = options; async function visit(dir: string): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; // unreadable dir — skip } for (const entry of entries) { const absolutePath = path.join(dir, entry.name); const relative = path.relative(cwd, absolutePath).replace(/\\/g, '/'); const ignored = ignorePatterns.some((p) => minimatch(relative, p, { dot: true }), ); if (ignored) continue; if (entry.isDirectory()) { if (includeDirs) results.push(absolutePath); await visit(absolutePath); } else if (entry.isFile()) { if (!includeDirs) results.push(absolutePath); } } } await visit(cwd); return results; } export async function findFiles( pattern: string, options: { cwd?: string; ignore?: string[] } = {}, ): Promise { const { cwd = process.cwd(), ignore = [] } = options; const ignorePatterns = [...DEFAULT_IGNORE, ...ignore]; const all = await walk(cwd, { includeDirs: false, ignorePatterns }); // Match absolute paths against the pattern using the relative form // (so patterns like "**/*.csproj" work as expected). return all.filter((absolute) => { const relative = path.relative(cwd, absolute).replace(/\\/g, '/'); return minimatch(relative, pattern, { dot: true }); }); } export async function findDirectories( pattern: string, options: { cwd?: string; ignore?: string[] } = {}, ): Promise { const { cwd = process.cwd(), ignore = [] } = options; const ignorePatterns = [...DEFAULT_IGNORE, ...ignore]; const all = await walk(cwd, { includeDirs: true, ignorePatterns }); return all.filter((absolute) => { const relative = path.relative(cwd, absolute).replace(/\\/g, '/'); return minimatch(relative, pattern, { dot: true }); }); } export function relativePath(from: string, to: string): string { return path.relative(from, to).replace(/\\/g, '/'); } export function normalizePath(filePath: string): string { return filePath.replace(/\\/g, '/'); }