Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Project files registry. Each project is a folder under
|
||||
// <projectRoot>/.ocd/projects/<projectId>/. The frontend's project list
|
||||
// (localStorage) carries metadata; this module is the single owner of the
|
||||
// on-disk content (HTML artifacts, sketches, uploaded images, pasted text).
|
||||
//
|
||||
// All paths flowing in from HTTP handlers are validated against the project
|
||||
// directory to prevent path traversal — see resolveSafe().
|
||||
|
||||
import { mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const FORBIDDEN_NAME = /[\\/]|^\.\.?$/;
|
||||
|
||||
export function projectDir(projectsRoot, projectId) {
|
||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||
return path.join(projectsRoot, projectId);
|
||||
}
|
||||
|
||||
export async function ensureProject(projectsRoot, projectId) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
await mkdir(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export async function listFiles(projectsRoot, projectId) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const full = path.join(dir, e.name);
|
||||
const st = await stat(full);
|
||||
out.push({
|
||||
name: e.name,
|
||||
// The project folder is flat today so `path` equals `name`. We emit
|
||||
// both so frontend code that thinks in path terms (the @-mention
|
||||
// picker, attachment chips) can stay path-shaped without a remap.
|
||||
path: e.name,
|
||||
type: 'file',
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
kind: kindFor(e.name),
|
||||
mime: mimeFor(e.name),
|
||||
});
|
||||
}
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function readProjectFile(projectsRoot, projectId, name) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
const file = resolveSafe(dir, name);
|
||||
const buf = await readFile(file);
|
||||
const st = await stat(file);
|
||||
return {
|
||||
buffer: buf,
|
||||
name: path.basename(file),
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
mime: mimeFor(file),
|
||||
kind: kindFor(file),
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeProjectFile(
|
||||
projectsRoot,
|
||||
projectId,
|
||||
name,
|
||||
body,
|
||||
{ overwrite = true } = {},
|
||||
) {
|
||||
const dir = await ensureProject(projectsRoot, projectId);
|
||||
const safeName = sanitizeName(name);
|
||||
const target = path.join(dir, safeName);
|
||||
if (!overwrite) {
|
||||
try {
|
||||
await stat(target);
|
||||
throw new Error('file already exists');
|
||||
} catch (err) {
|
||||
if (!err || err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
}
|
||||
await writeFile(target, body);
|
||||
const st = await stat(target);
|
||||
return {
|
||||
name: safeName,
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
kind: kindFor(safeName),
|
||||
mime: mimeFor(safeName),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteProjectFile(projectsRoot, projectId, name) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
const file = resolveSafe(dir, name);
|
||||
await unlink(file);
|
||||
}
|
||||
|
||||
export async function removeProjectDir(projectsRoot, projectId) {
|
||||
const dir = projectDir(projectsRoot, projectId);
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function resolveSafe(dir, name) {
|
||||
if (typeof name !== 'string' || !name || FORBIDDEN_NAME.test(name)) {
|
||||
throw new Error('invalid file name');
|
||||
}
|
||||
const target = path.resolve(dir, name);
|
||||
if (!target.startsWith(dir + path.sep) && target !== dir) {
|
||||
throw new Error('path escapes project dir');
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
// Replace anything outside [A-Za-z0-9._-] with underscore. Spaces collapse
|
||||
// to dashes (matches the kebab-case style used by the agent's slugs).
|
||||
export function sanitizeName(raw) {
|
||||
const cleaned = String(raw ?? '')
|
||||
.replace(/[\\/]/g, '_')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^A-Za-z0-9._-]/g, '_')
|
||||
.replace(/^\.+/, '_')
|
||||
.trim();
|
||||
return cleaned || `file-${Date.now()}`;
|
||||
}
|
||||
|
||||
function isSafeId(id) {
|
||||
return typeof id === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(id);
|
||||
}
|
||||
|
||||
const EXT_MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.cjs': 'text/javascript; charset=utf-8',
|
||||
'.ts': 'text/typescript; charset=utf-8',
|
||||
'.tsx': 'text/typescript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
};
|
||||
|
||||
export function mimeFor(name) {
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
return EXT_MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
// Coarse kind buckets the frontend uses to pick a viewer.
|
||||
export function kindFor(name) {
|
||||
// Editable sketches use a compound extension so they slot into the
|
||||
// "sketch" bucket while still being valid JSON on disk.
|
||||
if (name.endsWith('.sketch.json')) return 'sketch';
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
if (ext === '.html' || ext === '.htm') return 'html';
|
||||
if (ext === '.svg') return 'sketch';
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif'].includes(ext)) {
|
||||
if (name.startsWith('sketch-')) return 'sketch';
|
||||
return 'image';
|
||||
}
|
||||
if (['.md', '.txt'].includes(ext)) return 'text';
|
||||
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css'].includes(ext)) {
|
||||
return 'code';
|
||||
}
|
||||
return 'binary';
|
||||
}
|
||||
Reference in New Issue
Block a user