open-design/apps/daemon/src/projects.ts
Marc Chan c3d9136a0c
Add live artifacts and Composio connector catalog (#381)
* docs: add live artifacts implementation spec

* docs: align live artifacts implementation plan

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 7: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 10: work in progress

* Ralph iteration 11: work in progress

* Ralph iteration 12: work in progress

* Ralph iteration 13: work in progress

* Ralph iteration 14: work in progress

* Ralph iteration 15: work in progress

* Ralph iteration 16: work in progress

* Ralph iteration 17: work in progress

* Ralph iteration 18: work in progress

* Ralph iteration 19: work in progress

* Ralph iteration 20: work in progress

* Ralph iteration 21: work in progress

* Ralph iteration 22: work in progress

* Ralph iteration 23: work in progress

* Ralph iteration 24: work in progress

* Ralph iteration 25: work in progress

* Ralph iteration 26: work in progress

* Ralph iteration 27: work in progress

* Ralph iteration 28: work in progress

* Ralph iteration 29: work in progress

* Ralph iteration 30: work in progress

* Ralph iteration 31: work in progress

* Ralph iteration 32: work in progress

* Ralph iteration 33: work in progress

* Ralph iteration 34: work in progress

* Ralph iteration 35: work in progress

* Ralph iteration 36: work in progress

* Ralph iteration 37: work in progress

* Ralph iteration 38: work in progress

* Ralph iteration 39: work in progress

* Ralph iteration 40: work in progress

* Ralph iteration 41: work in progress

* Ralph iteration 42: work in progress

* Ralph iteration 43: work in progress

* Ralph iteration 44: work in progress

* Ralph iteration 45: work in progress

* Ralph iteration 46: work in progress

* Ralph iteration 47: work in progress

* Ralph iteration 48: work in progress

* Ralph iteration 49: work in progress

* Ralph iteration 50: work in progress

* Ralph iteration 51: work in progress

* Ralph iteration 52: work in progress

* Ralph iteration 53: work in progress

* Ralph iteration 54: work in progress

* Ralph iteration 55: work in progress

* Ralph iteration 56: work in progress

* Ralph iteration 57: work in progress

* Ralph iteration 58: work in progress

* Ralph iteration 59: work in progress

* Ralph iteration 60: work in progress

* Ralph iteration 61: work in progress

* Ralph iteration 62: work in progress

* Ralph iteration 63: work in progress

* Ralph iteration 64: work in progress

* Ralph iteration 65: work in progress

* Ralph iteration 1: work in progress

* Ralph iteration 2: work in progress

* Ralph iteration 3: work in progress

* Ralph iteration 4: work in progress

* Ralph iteration 5: work in progress

* Ralph iteration 6: work in progress

* Ralph iteration 8: work in progress

* Ralph iteration 9: work in progress

* Ralph iteration 17: work in progress

* Add Composio-backed connectors

* Add Composio-backed connector catalog

* Fix connector callback flow

* Update live artifact connector refresh

* Fix live artifact refresh updates

* Improve live artifact viewer toolbar

* Refine live artifact source tabs

* Expand Composio connector catalog

* Improve Composio connector browsing

* Fix artifact refresh source safety checks

Generated-By: looper 0.4.1 (runner=fixer, agent=opencode)

* Fix live artifacts PR feedback

Generated-By: looper 0.5.0 (runner=fixer, agent=opencode)

* Fix live artifact preview CORS validation

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix connector OAuth IPv6 loopback hosts

Allow bracketed IPv6 loopback Host headers when deriving connector OAuth callback URLs so IPv6-bound daemons can complete connection flow.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Preserve live artifact refresh permissions

Respect explicit refresh permission choices during live artifact create and update flows so revoked connector sources remain gated.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact preview cache freshness

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact refresh validation

Guard manual refreshes with local daemon checks and reject daemon_tool sources without a toolName before refresh execution.

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix Composio credential invalidation

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix live artifact CORS methods

Generated-By: looper 0.0.0-dev (runner=fixer, agent=opencode)

* Fix workspace validation

Restore media config test isolation under Vitest setup data-dir overrides and add the missing French live artifact display copy so the workspace test suite stays aligned.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector safety filtering

Keep agent-preview connector listings aligned with execution safety policy and prune stale Composio OAuth state records before they accumulate.

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix agent runtime cleanup

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix live artifact daemon access

Validate local-only live artifact routes against the peer socket address and pass daemon-resolved CLI paths to ACP MCP descriptors.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector run limit pruning

Evict stale connector rate-limit buckets so long-lived daemon processes do not retain per-run entries indefinitely.\n\nGenerated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Fix connector compact schemas

Generated-By: looper 0.5.2 (runner=fixer, agent=opencode)

* Improve connector connection feedback

* Adjust connector gate positioning

* Fix live artifact refresh commits

Avoid marking refresh candidates failed after snapshot or state persistence errors by deferring live artifact mutations until the durable refresh metadata is written. Also align connector OAuth callback host validation with daemon loopback handling.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Improve connector search relevance

* fix(daemon): harden connector connection state

Require loopback daemon validation before connector connect side effects and only clear provider-owned connector statuses during credential reset.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): guard connector disconnect route

Require local daemon request validation before connector disconnect side effects.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): guard composio config updates

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): dispatch live artifacts mcp first

Route the live-artifacts MCP server before the generic MCP CLI so od mcp live-artifacts starts the dedicated server instead of failing generic argument parsing.\n\nGenerated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix(daemon): handle integer connector schemas

Allow JSON Schema integer connector inputs while preserving fractional-value validation so generated connector tool schemas accept valid page sizes and limits.

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* fix: align live artifact refresh error codes

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Fix live artifact connector refresh flow

* Update live artifact design cards

* Add beta badge to live artifact form

* Remove live artifact tile model

* Fix live artifact refresh sync

* Fix live artifact MCP refresh durability

Generated-By: looper 0.5.4 (runner=fixer, agent=opencode)

* Fix live artifact refresh safety

Enforce persisted refresh opt-out and connector auto-read gating before refresh sources execute.

Generated-By: looper 0.5.5 (runner=fixer, agent=opencode)
2026-05-05 16:42:11 +08:00

579 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-nocheck
// Project files registry. Each project is a folder under
// <projectRoot>/.od/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 { lstat, mkdir, readdir, readFile, rm, stat, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import JSZip from 'jszip';
import {
inferLegacyManifest,
parsePersistedManifest,
validateArtifactManifestInput,
} from './artifact-manifest.js';
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
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, opts = {}) {
const dir = projectDir(projectsRoot, projectId);
const out = [];
await collectFiles(dir, '', out);
// Newest first — matches the visual order users expect after generating.
out.sort((a, b) => b.mtime - a.mtime);
const since = Number(opts.since);
if (Number.isFinite(since) && since > 0) {
return out.filter((f) => Number(f.mtime) > since);
}
return out;
}
async function collectFiles(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectFiles(full, rel, out);
continue;
}
if (!e.isFile()) continue;
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
const manifest = await readManifestForPath(dir, rel);
out.push({
name: rel,
path: rel,
type: 'file',
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(rel),
mime: mimeFor(rel),
artifactKind: manifest?.kind,
artifactManifest: manifest,
});
}
}
// Build a ZIP of every file under the project directory (or under `root`,
// if it points at a subdirectory). Mirrors listFiles' filtering — dotfiles
// and `.artifact.json` sidecars are excluded — so the archive matches what
// the user sees in the file panel. Used by the "Download as .zip" share
// menu item, which exports the user's actual project tree (e.g. the
// uploaded `ui-design/` folder), not just the rendered HTML.
export async function buildProjectArchive(projectsRoot, projectId, root) {
const projectRoot = projectDir(projectsRoot, projectId);
let archiveRoot = projectRoot;
let archiveBaseName = '';
if (typeof root === 'string' && root.trim().length > 0) {
archiveRoot = resolveSafe(projectRoot, root);
archiveBaseName = path.basename(archiveRoot);
}
// Stat the archive root up-front so a missing/non-directory target gives a
// clear ENOENT/ENOTDIR error. Without this the recursive walk swallows
// ENOENT and we'd report the directory as "empty" instead — confusing if
// the project (or a subdir) was deleted concurrently with the download.
let rootStat;
try {
rootStat = await stat(archiveRoot);
} catch (err) {
if (err && err.code === 'ENOENT') {
const e = new Error('archive root does not exist');
e.code = 'ENOENT';
throw e;
}
throw err;
}
if (!rootStat.isDirectory()) {
const err = new Error('archive root is not a directory');
err.code = 'ENOTDIR';
throw err;
}
const entries = [];
await collectArchiveEntries(archiveRoot, '', entries);
if (entries.length === 0) {
const err = new Error('archive root is empty');
err.code = 'ENOENT';
throw err;
}
const zip = new JSZip();
for (const entry of entries) {
const buf = await readFile(entry.fullPath);
zip.file(entry.relPath, buf, {
date: new Date(entry.mtime),
binary: true,
});
}
// Level 6 is the zlib default — balances speed and ratio for typical
// project trees (HTML/CSS/JS plus a handful of assets). Level 9 buys
// <5% on already-compressed PNGs/fonts at 2-3× CPU; level 1 produces
// noticeably larger archives. Revisit only if profiling says so.
const buffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return { buffer, baseName: archiveBaseName };
}
export async function buildBatchArchive(projectsRoot, projectId, fileNames) {
const projectRoot = projectDir(projectsRoot, projectId);
const zip = new JSZip();
let packed = 0;
const rejected = [];
for (const name of fileNames) {
let filePath;
try {
filePath = resolveSafe(projectRoot, name);
} catch (err) {
rejected.push({ name, reason: `invalid path: ${err?.message || err}` });
continue;
}
// Mirror the visible-file allowlist from collectFiles/collectArchiveEntries:
// reject any hidden segment, .artifact.json sidecars, and symlinks at any
// level of the path (not just the final basename).
const relSegments = path.relative(projectRoot, filePath).split(path.sep);
let hidden = false;
for (const seg of relSegments) {
if (seg.startsWith('.')) {
hidden = true;
break;
}
}
if (hidden) {
rejected.push({ name, reason: 'hidden segments are not eligible for archive' });
continue;
}
if (path.basename(filePath).endsWith('.artifact.json')) {
rejected.push({ name, reason: 'artifact sidecars are not eligible for archive' });
continue;
}
// Walk each path segment from projectRoot to the target with lstat,
// rejecting intermediate symlinks that could escape the project tree.
let walk = projectRoot;
let symlinkFound = false;
for (const seg of relSegments) {
walk = path.join(walk, seg);
let segStat;
try {
segStat = await lstat(walk);
} catch (err) {
if (err && err.code === 'ENOENT') {
rejected.push({ name, reason: `segment not found: ${seg}` });
break;
}
throw err;
}
if (segStat.isSymbolicLink()) {
symlinkFound = true;
break;
}
}
if (symlinkFound) {
rejected.push({ name, reason: 'symlinks are not eligible for archive' });
continue;
}
if (rejected.length > 0 && rejected[rejected.length - 1].name === name) continue;
// Final stat on the resolved path (guards against TOCTOU between segment
// walk and read, and catches non-regular files).
let st;
try {
st = await lstat(filePath);
} catch (err) {
if (err && err.code === 'ENOENT') {
rejected.push({ name, reason: 'file not found' });
continue;
}
throw err;
}
if (st.isSymbolicLink()) {
rejected.push({ name, reason: 'symlinks are not eligible for archive' });
continue;
}
if (!st.isFile()) {
rejected.push({ name, reason: 'not a regular file' });
continue;
}
const buf = await readFile(filePath);
zip.file(name, buf, {
date: new Date(st.mtimeMs),
binary: true,
});
packed += 1;
}
// Fail-fast: any rejected entry means the request is invalid — mirror the
// strict rejection semantics of the panel and full archive.
if (rejected.length > 0) {
const err = new Error(
`${rejected.length} file(s) ineligible for archive: ${rejected.map((r) => r.name).join(', ')}`,
);
err.code = 'BAD_REQUEST';
err.rejected = rejected;
throw err;
}
if (packed === 0) {
const err = new Error('no files could be packed');
err.code = 'ENOENT';
throw err;
}
const buffer = await zip.generateAsync({
type: 'nodebuffer',
compression: 'DEFLATE',
compressionOptions: { level: 6 },
});
return { buffer, baseName: '' };
}
async function collectArchiveEntries(dir, relDir, out) {
let entries = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch (err) {
if (err && err.code === 'ENOENT') return;
throw err;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
if (!e.isDirectory() && !e.isFile()) continue;
const rel = relDir ? `${relDir}/${e.name}` : e.name;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
await collectArchiveEntries(full, rel, out);
continue;
}
if (e.name.endsWith('.artifact.json')) continue;
const st = await stat(full);
out.push({ relPath: rel, fullPath: full, mtime: st.mtimeMs });
}
}
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);
const rel = toProjectPath(path.relative(dir, file));
const manifest = await readManifestForPath(dir, rel);
return {
buffer: buf,
name: rel,
path: rel,
size: st.size,
mtime: st.mtimeMs,
mime: mimeFor(rel),
kind: kindFor(rel),
artifactKind: manifest?.kind,
artifactManifest: manifest,
};
}
export async function writeProjectFile(
projectsRoot,
projectId,
name,
body,
{ overwrite = true, artifactManifest = null } = {},
) {
const dir = await ensureProject(projectsRoot, projectId);
const safeName = sanitizePath(name);
const target = resolveSafe(dir, safeName);
if (!overwrite) {
try {
await stat(target);
throw new Error('file already exists');
} catch (err) {
if (!err || err.code !== 'ENOENT') throw err;
}
}
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, body);
if (artifactManifest && typeof artifactManifest === 'object') {
const manifestFileName = artifactManifestNameFor(safeName);
const manifestTarget = resolveSafe(dir, manifestFileName);
const validated = validateArtifactManifestInput(artifactManifest, safeName);
if (validated.ok && validated.value) {
const nextManifest = validated.value;
await writeFile(manifestTarget, JSON.stringify(nextManifest, null, 2));
}
}
const st = await stat(target);
const persistedManifest = await readManifestForPath(dir, safeName);
return {
name: safeName,
path: safeName,
size: st.size,
mtime: st.mtimeMs,
kind: kindFor(safeName),
mime: mimeFor(safeName),
artifactKind: persistedManifest?.kind,
artifactManifest: persistedManifest,
};
}
function artifactManifestNameFor(name) {
return `${name}.artifact.json`;
}
async function readManifestForPath(projectDirPath, relPath) {
const manifestPath = path.join(projectDirPath, artifactManifestNameFor(relPath));
try {
const raw = await readFile(manifestPath, 'utf8');
const parsed = parseManifest(raw);
if (parsed) return parsed;
} catch (err) {
if (!err || err.code !== 'ENOENT') {
// ignore malformed/invalid manifests and fallback to inference
}
}
return inferLegacyManifest(relPath);
}
function parseManifest(raw) {
return parsePersistedManifest(raw, '');
}
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) {
const safePath = validateProjectPath(name);
const target = path.resolve(dir, safePath);
if (!target.startsWith(dir + path.sep) && target !== dir) {
throw new Error('path escapes project dir');
}
return target;
}
export function sanitizePath(raw) {
const normalized = validateProjectPath(raw);
return normalized.split('/').map(sanitizeName).join('/');
}
export function validateProjectPath(raw) {
if (typeof raw !== 'string' || !raw.trim()) {
throw new Error('invalid file name');
}
const normalized = raw.replace(/\\/g, '/');
if (raw.includes('\0') || /^[A-Za-z]:/.test(normalized) || normalized.startsWith('/')) {
throw new Error('invalid file name');
}
const parts = normalized.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((p) => FORBIDDEN_SEGMENT.test(p))) {
throw new Error('invalid file name');
}
if (parts.some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part))) {
throw new Error('reserved project path');
}
return parts.join('/');
}
export function isReservedProjectFilePath(raw) {
try {
const normalized = String(raw ?? '').replace(/\\/g, '/');
return normalized.split('/').filter(Boolean).some((part) => RESERVED_PROJECT_FILE_SEGMENTS.has(part));
} catch {
return false;
}
}
// Keep Unicode letters/digits as-is; replace path separators, control
// characters, and reserved punctuation with underscore. Spaces collapse
// to dashes (matches the kebab-case style used by the agent's slugs).
// The previous ASCII-only filter collapsed every non-ASCII character to
// '_', so a Chinese filename like '测试文档.docx' became '____.docx'
// (issue #144).
export function sanitizeName(raw) {
const cleaned = String(raw ?? '')
.replace(/[\\/]/g, '_')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}._-]/gu, '_')
.replace(/^\.+/, '_')
.trim();
return cleaned || `file-${Date.now()}`;
}
// multer@1 decodes multipart filenames as latin1, which mangles any
// UTF-8 bytes (Chinese, Japanese, Cyrillic, ...) the user uploads. Re-
// decode as UTF-8 when the result round-trips back to the original
// bytes; otherwise the source was genuine latin1 and we leave it alone.
export function decodeMultipartFilename(name) {
if (!name || typeof name !== 'string') return name ?? '';
// If any code point exceeds 0xFF the source is already a properly
// decoded Unicode string — for example, multer received an RFC 5987
// `filename*` parameter and decoded it as UTF-8. Re-running latin1
// -> utf8 here would corrupt those names, so exit early.
for (let i = 0; i < name.length; i++) {
if (name.charCodeAt(i) > 0xff) return name;
}
const buf = Buffer.from(name, 'latin1');
const utf8 = buf.toString('utf8');
return Buffer.from(utf8, 'utf8').equals(buf) ? utf8 : name;
}
function toProjectPath(raw) {
return raw.split(path.sep).join('/');
}
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',
'.jsx': 'text/javascript; charset=utf-8',
'.ts': 'text/typescript; charset=utf-8',
// `.tsx` previously served as `text/typescript`, which browser module
// loaders and strict CSPs do not accept as a JavaScript MIME. Multi-file
// React prototypes that load `.tsx` via Babel-standalone (`<script
// type="text/babel" src="…">`) need a JS-family Content-Type for the
// browser fetch to succeed. Upstream of issue #336.
'.tsx': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.pdf': 'application/pdf',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.mp4': 'video/mp4',
'.mov': 'video/quicktime',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
};
export function mimeFor(name) {
const ext = path.extname(name).toLowerCase();
return EXT_MIME[ext] || 'application/octet-stream';
}
export async function searchProjectFiles(projectsRoot, projectId, query, opts = {}) {
const max = Math.min(Number(opts.max) || 200, 1000);
const pattern = opts.pattern || null;
const items = await listFiles(projectsRoot, projectId);
const dir = projectDir(projectsRoot, projectId);
const escaped = String(query).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(escaped, 'i');
const matches = [];
for (const f of items) {
if (!isTextualMime(f.mime)) continue;
if (pattern && !globMatch(f.name, pattern)) continue;
let content;
try {
content = await readFile(path.join(dir, f.name), 'utf8');
} catch {
continue;
}
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (re.test(lines[i])) {
const snippet = lines[i].length > 220 ? lines[i].slice(0, 220) + '…' : lines[i];
matches.push({ file: f.name, line: i + 1, snippet });
if (matches.length >= max) return matches;
}
}
}
return matches;
}
function isTextualMime(mime) {
if (!mime) return false;
return (
/^text\//i.test(mime) ||
/^application\/(json|javascript|typescript|xml|x-(?:yaml|toml|httpd-php|sh))\b/i.test(mime) ||
/\+(?:json|xml)\b/i.test(mime) ||
/^image\/svg\+xml/i.test(mime)
);
}
function globMatch(name, glob) {
const re = new RegExp(
'^' +
glob
.split('*')
.map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
.join('.*') +
'$',
);
return re.test(name);
}
// 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 (['.mp4', '.mov', '.webm'].includes(ext)) return 'video';
if (['.mp3', '.wav', '.m4a'].includes(ext)) return 'audio';
if (['.md', '.txt'].includes(ext)) return 'text';
if (['.js', '.mjs', '.cjs', '.ts', '.tsx', '.json', '.css', '.py'].includes(ext)) {
return 'code';
}
if (ext === '.pdf') return 'pdf';
if (ext === '.docx') return 'document';
if (ext === '.pptx') return 'presentation';
if (ext === '.xlsx') return 'spreadsheet';
return 'binary';
}