Open external context files read-only Preserve leading-dot paths in UI Keep workspace write operations guarded
853 lines
28 KiB
JavaScript
853 lines
28 KiB
JavaScript
const EXEC_JOB_TTL_MS = 30 * 60 * 1000;
|
|
|
|
const createCommandTimeoutMs = () => {
|
|
const raw = Number(process.env.OPENCHAMBER_FS_EXEC_TIMEOUT_MS);
|
|
if (Number.isFinite(raw) && raw > 0) return raw;
|
|
return 5 * 60 * 1000;
|
|
};
|
|
|
|
const isPathWithinRoot = (resolvedPath, rootPath, path, os) => {
|
|
const resolvedRoot = path.resolve(rootPath || os.homedir());
|
|
const relative = path.relative(resolvedRoot, resolvedPath);
|
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const resolveWorkspacePath = ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
if (isPathWithinRoot(resolved, resolvedBase, path, os)) {
|
|
return { ok: true, base: resolvedBase, resolved };
|
|
}
|
|
|
|
if (isPathWithinRoot(resolved, openchamberUserConfigRoot, path, os)) {
|
|
return { ok: true, base: path.resolve(openchamberUserConfigRoot), resolved };
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromWorktrees = async ({ targetPath, baseDirectory, path, os, normalizeDirectoryPath }) => {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
|
|
const resolved = path.resolve(normalized);
|
|
const resolvedBase = path.resolve(baseDirectory || os.homedir());
|
|
|
|
try {
|
|
const { getWorktrees } = await import('../git/index.js');
|
|
const worktrees = await getWorktrees(resolvedBase);
|
|
|
|
for (const worktree of worktrees) {
|
|
const candidatePath = typeof worktree?.path === 'string'
|
|
? worktree.path
|
|
: (typeof worktree?.worktree === 'string' ? worktree.worktree : '');
|
|
const candidate = normalizeDirectoryPath(candidatePath);
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const candidateResolved = path.resolve(candidate);
|
|
if (isPathWithinRoot(resolved, candidateResolved, path, os)) {
|
|
return { ok: true, base: candidateResolved, resolved };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to resolve worktree roots:', error);
|
|
}
|
|
|
|
return { ok: false, error: 'Path is outside of active workspace' };
|
|
};
|
|
|
|
const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
const resolvedProject = await resolveProjectDirectory(req);
|
|
if (!resolvedProject.directory) {
|
|
return { ok: false, error: resolvedProject.error || 'Active workspace is required' };
|
|
}
|
|
|
|
const resolved = resolveWorkspacePath({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (resolved.ok || resolved.error !== 'Path is outside of active workspace') {
|
|
return resolved;
|
|
}
|
|
|
|
return resolveWorkspacePathFromWorktrees({
|
|
targetPath,
|
|
baseDirectory: resolvedProject.directory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
});
|
|
};
|
|
|
|
const resolveReadPathFromContext = async ({ req, targetPath, resolveProjectDirectory, path, os, normalizeDirectoryPath, openchamberUserConfigRoot }) => {
|
|
if (req.query?.allowOutsideWorkspace === 'true') {
|
|
const normalized = normalizeDirectoryPath(targetPath);
|
|
if (!normalized || typeof normalized !== 'string') {
|
|
return { ok: false, error: 'Path is required' };
|
|
}
|
|
const resolved = path.resolve(normalized);
|
|
return { ok: true, base: path.dirname(resolved), resolved };
|
|
}
|
|
|
|
return resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
};
|
|
|
|
const runCommandInDirectory = ({ shell, shellFlag, command, resolvedCwd, spawn, buildAugmentedPath, commandTimeoutMs }) => {
|
|
return new Promise((resolve) => {
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let timedOut = false;
|
|
|
|
const envPath = buildAugmentedPath();
|
|
const execEnv = { ...process.env, PATH: envPath };
|
|
|
|
const child = spawn(shell, [shellFlag, command], {
|
|
cwd: resolvedCwd,
|
|
env: execEnv,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
try {
|
|
child.kill('SIGKILL');
|
|
} catch {
|
|
}
|
|
}, commandTimeoutMs);
|
|
|
|
child.stdout?.on('data', (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
child.stderr?.on('data', (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
command,
|
|
success: false,
|
|
exitCode: undefined,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
});
|
|
|
|
child.on('close', (code, signal) => {
|
|
clearTimeout(timeout);
|
|
const exitCode = typeof code === 'number' ? code : undefined;
|
|
const base = {
|
|
command,
|
|
success: exitCode === 0 && !timedOut,
|
|
exitCode,
|
|
stdout: stdout.trim(),
|
|
stderr: stderr.trim(),
|
|
};
|
|
|
|
if (timedOut) {
|
|
resolve({
|
|
...base,
|
|
success: false,
|
|
error: `Command timed out after ${commandTimeoutMs}ms` + (signal ? ` (${signal})` : ''),
|
|
});
|
|
return;
|
|
}
|
|
|
|
resolve(base);
|
|
});
|
|
});
|
|
};
|
|
|
|
export const registerFsRoutes = (app, dependencies) => {
|
|
const {
|
|
os,
|
|
path,
|
|
fsPromises,
|
|
spawn,
|
|
crypto,
|
|
normalizeDirectoryPath,
|
|
resolveProjectDirectory,
|
|
buildAugmentedPath,
|
|
resolveGitBinaryForSpawn,
|
|
openchamberUserConfigRoot,
|
|
} = dependencies;
|
|
|
|
const execJobs = new Map();
|
|
const commandTimeoutMs = createCommandTimeoutMs();
|
|
|
|
const pruneExecJobs = () => {
|
|
const now = Date.now();
|
|
for (const [jobId, job] of execJobs.entries()) {
|
|
if (!job || typeof job !== 'object') {
|
|
execJobs.delete(jobId);
|
|
continue;
|
|
}
|
|
const updatedAt = typeof job.updatedAt === 'number' ? job.updatedAt : 0;
|
|
if (updatedAt && now - updatedAt > EXEC_JOB_TTL_MS) {
|
|
execJobs.delete(jobId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const runExecJob = async (job) => {
|
|
job.status = 'running';
|
|
job.updatedAt = Date.now();
|
|
|
|
const results = [];
|
|
for (const command of job.commands) {
|
|
if (typeof command !== 'string' || !command.trim()) {
|
|
results.push({ command, success: false, error: 'Invalid command' });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const result = await runCommandInDirectory({
|
|
shell: job.shell,
|
|
shellFlag: job.shellFlag,
|
|
command,
|
|
resolvedCwd: job.resolvedCwd,
|
|
spawn,
|
|
buildAugmentedPath,
|
|
commandTimeoutMs,
|
|
});
|
|
results.push(result);
|
|
} catch (error) {
|
|
results.push({
|
|
command,
|
|
success: false,
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
}
|
|
|
|
job.results = results;
|
|
job.updatedAt = Date.now();
|
|
}
|
|
|
|
job.results = results;
|
|
job.success = results.every((r) => r.success);
|
|
job.status = 'done';
|
|
job.finishedAt = Date.now();
|
|
job.updatedAt = Date.now();
|
|
};
|
|
|
|
app.get('/api/fs/home', (_req, res) => {
|
|
try {
|
|
const home = os.homedir();
|
|
if (!home || typeof home !== 'string' || home.length === 0) {
|
|
return res.status(500).json({ error: 'Failed to resolve home directory' });
|
|
}
|
|
return res.json({ home });
|
|
} catch (error) {
|
|
console.error('Failed to resolve home directory:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to resolve home directory' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/mkdir', async (req, res) => {
|
|
try {
|
|
const { path: dirPath, allowOutsideWorkspace } = req.body ?? {};
|
|
if (typeof dirPath !== 'string' || !dirPath.trim()) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
let resolvedPath = '';
|
|
if (allowOutsideWorkspace) {
|
|
resolvedPath = path.resolve(normalizeDirectoryPath(dirPath));
|
|
} else {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: dirPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
resolvedPath = resolved.resolved;
|
|
}
|
|
|
|
await fsPromises.mkdir(resolvedPath, { recursive: true });
|
|
return res.json({ success: true, path: resolvedPath });
|
|
} catch (error) {
|
|
console.error('Failed to create directory:', error);
|
|
return res.status(500).json({ error: error.message || 'Failed to create directory' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/stat', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|
fsPromises.realpath(resolved.resolved),
|
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
|
]);
|
|
|
|
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
return res.json({ path: canonicalPath, isFile: true, size: stats.size, mtimeMs: stats.mtimeMs });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
console.error('Failed to stat file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to stat file' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/read', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|
fsPromises.realpath(resolved.resolved),
|
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
|
]);
|
|
|
|
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
const content = await fsPromises.readFile(canonicalPath, 'utf8');
|
|
return res.type('text/plain').send(content);
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
console.error('Failed to read file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/raw', async (req, res) => {
|
|
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
if (!filePath) {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveReadPathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
const [canonicalPath, canonicalBase] = await Promise.all([
|
|
fsPromises.realpath(resolved.resolved),
|
|
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
|
]);
|
|
|
|
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
|
|
const stats = await fsPromises.stat(canonicalPath);
|
|
if (!stats.isFile()) {
|
|
return res.status(400).json({ error: 'Specified path is not a file' });
|
|
}
|
|
|
|
const ext = path.extname(canonicalPath).toLowerCase();
|
|
const mimeMap = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.ico': 'image/x-icon',
|
|
'.bmp': 'image/bmp',
|
|
'.avif': 'image/avif',
|
|
};
|
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
|
|
|
const download = req.query.download === 'true';
|
|
if (download) {
|
|
const fileName = path.basename(canonicalPath);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
|
}
|
|
|
|
const content = await fsPromises.readFile(canonicalPath);
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
return res.type(mimeType).send(content);
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access to file denied' });
|
|
}
|
|
console.error('Failed to read raw file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/write', async (req, res) => {
|
|
const { path: filePath, content } = req.body || {};
|
|
if (!filePath || typeof filePath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
if (typeof content !== 'string') {
|
|
return res.status(400).json({ error: 'Content is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: filePath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
|
|
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
console.error('Failed to write file:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to write file' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/delete', async (req, res) => {
|
|
const { path: targetPath } = req.body || {};
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolved.ok) {
|
|
return res.status(400).json({ error: resolved.error });
|
|
}
|
|
|
|
await fsPromises.rm(resolved.resolved, { recursive: true, force: true });
|
|
return res.json({ success: true, path: resolved.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'File or directory not found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
console.error('Failed to delete path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to delete path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/rename', async (req, res) => {
|
|
const { oldPath, newPath } = req.body || {};
|
|
if (!oldPath || typeof oldPath !== 'string') {
|
|
return res.status(400).json({ error: 'oldPath is required' });
|
|
}
|
|
if (!newPath || typeof newPath !== 'string') {
|
|
return res.status(400).json({ error: 'newPath is required' });
|
|
}
|
|
|
|
try {
|
|
const resolvedOld = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: oldPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolvedOld.ok) {
|
|
return res.status(400).json({ error: resolvedOld.error });
|
|
}
|
|
|
|
const resolvedNew = await resolveWorkspacePathFromContext({
|
|
req,
|
|
targetPath: newPath,
|
|
resolveProjectDirectory,
|
|
path,
|
|
os,
|
|
normalizeDirectoryPath,
|
|
openchamberUserConfigRoot,
|
|
});
|
|
if (!resolvedNew.ok) {
|
|
return res.status(400).json({ error: resolvedNew.error });
|
|
}
|
|
|
|
if (resolvedOld.base !== resolvedNew.base) {
|
|
return res.status(400).json({ error: 'Source and destination must share the same workspace root' });
|
|
}
|
|
|
|
await fsPromises.rename(resolvedOld.resolved, resolvedNew.resolved);
|
|
return res.json({ success: true, path: resolvedNew.resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Source path not found' });
|
|
}
|
|
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access denied' });
|
|
}
|
|
console.error('Failed to rename path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to rename path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/reveal', async (req, res) => {
|
|
const { path: targetPath } = req.body || {};
|
|
if (!targetPath || typeof targetPath !== 'string') {
|
|
return res.status(400).json({ error: 'Path is required' });
|
|
}
|
|
|
|
try {
|
|
const resolved = path.resolve(targetPath.trim());
|
|
await fsPromises.access(resolved);
|
|
|
|
const platform = process.platform;
|
|
if (platform === 'darwin') {
|
|
const stat = await fsPromises.stat(resolved);
|
|
if (stat.isDirectory()) {
|
|
spawn('open', [resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
|
} else {
|
|
spawn('open', ['-R', resolved], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
|
}
|
|
} else if (platform === 'win32') {
|
|
const stat = await fsPromises.stat(resolved);
|
|
const escapedPath = resolved.replace(/'/g, "''");
|
|
const explorerArg = stat.isDirectory() ? escapedPath : `/select,${escapedPath}`;
|
|
const command = `Start-Process -FilePath explorer.exe -ArgumentList '${explorerArg}'`;
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], {
|
|
windowsHide: true,
|
|
stdio: 'ignore',
|
|
});
|
|
child.once('error', reject);
|
|
child.once('exit', (code) => {
|
|
if (code === 0) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(new Error(`Explorer launch failed with code ${code ?? 'unknown'}`));
|
|
});
|
|
});
|
|
} else {
|
|
const stat = await fsPromises.stat(resolved);
|
|
const dir = stat.isDirectory() ? resolved : path.dirname(resolved);
|
|
spawn('xdg-open', [dir], { windowsHide: true, stdio: 'ignore', detached: true }).unref();
|
|
}
|
|
|
|
return res.json({ success: true, path: resolved });
|
|
} catch (error) {
|
|
const err = error;
|
|
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
|
return res.status(404).json({ error: 'Path not found' });
|
|
}
|
|
console.error('Failed to reveal path:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to reveal path' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/fs/exec', async (req, res) => {
|
|
const { commands, cwd, background } = req.body || {};
|
|
if (!Array.isArray(commands) || commands.length === 0) {
|
|
return res.status(400).json({ error: 'Commands array is required' });
|
|
}
|
|
if (!cwd || typeof cwd !== 'string') {
|
|
return res.status(400).json({ error: 'Working directory (cwd) is required' });
|
|
}
|
|
|
|
pruneExecJobs();
|
|
|
|
try {
|
|
const resolvedCwd = path.resolve(normalizeDirectoryPath(cwd));
|
|
const stats = await fsPromises.stat(resolvedCwd);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified cwd is not a directory' });
|
|
}
|
|
|
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
|
|
const shellFlag = process.platform === 'win32' ? '/c' : '-c';
|
|
|
|
const jobId = crypto.randomUUID();
|
|
const job = {
|
|
jobId,
|
|
status: 'queued',
|
|
success: null,
|
|
commands,
|
|
resolvedCwd,
|
|
shell,
|
|
shellFlag,
|
|
results: [],
|
|
startedAt: Date.now(),
|
|
finishedAt: null,
|
|
updatedAt: Date.now(),
|
|
};
|
|
|
|
execJobs.set(jobId, job);
|
|
|
|
const isBackground = background === true;
|
|
if (isBackground) {
|
|
void runExecJob(job).catch((error) => {
|
|
job.status = 'done';
|
|
job.success = false;
|
|
job.results = Array.isArray(job.results) ? job.results : [];
|
|
job.results.push({
|
|
command: '',
|
|
success: false,
|
|
error: (error && error.message) || 'Command execution failed',
|
|
});
|
|
job.finishedAt = Date.now();
|
|
job.updatedAt = Date.now();
|
|
});
|
|
|
|
return res.status(202).json({
|
|
jobId,
|
|
status: 'running',
|
|
});
|
|
}
|
|
|
|
await runExecJob(job);
|
|
return res.json({
|
|
jobId,
|
|
status: job.status,
|
|
success: job.success === true,
|
|
results: job.results,
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to execute commands:', error);
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to execute commands' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/fs/exec/:jobId', (req, res) => {
|
|
const jobId = typeof req.params?.jobId === 'string' ? req.params.jobId : '';
|
|
if (!jobId) {
|
|
return res.status(400).json({ error: 'Job id is required' });
|
|
}
|
|
|
|
pruneExecJobs();
|
|
|
|
const job = execJobs.get(jobId);
|
|
if (!job) {
|
|
return res.status(404).json({ error: 'Job not found' });
|
|
}
|
|
|
|
job.updatedAt = Date.now();
|
|
return res.json({
|
|
jobId: job.jobId,
|
|
status: job.status,
|
|
success: job.success === true,
|
|
results: Array.isArray(job.results) ? job.results : [],
|
|
});
|
|
});
|
|
|
|
app.get('/api/fs/list', async (req, res) => {
|
|
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
|
? req.query.path.trim()
|
|
: os.homedir();
|
|
const respectGitignore = req.query.respectGitignore === 'true';
|
|
let resolvedPath = '';
|
|
|
|
const isPlansDirectory = (value) => {
|
|
if (!value || typeof value !== 'string') return false;
|
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
return normalized.endsWith('/.opencode/plans') || normalized.endsWith('.opencode/plans');
|
|
};
|
|
|
|
try {
|
|
resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
|
|
|
|
const stats = await fsPromises.stat(resolvedPath);
|
|
if (!stats.isDirectory()) {
|
|
return res.status(400).json({ error: 'Specified path is not a directory' });
|
|
}
|
|
|
|
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
|
|
let ignoredPaths = new Set();
|
|
if (respectGitignore) {
|
|
try {
|
|
const pathsToCheck = dirents.map((d) => d.name);
|
|
if (pathsToCheck.length > 0) {
|
|
try {
|
|
const result = await new Promise((resolve) => {
|
|
const child = spawn(resolveGitBinaryForSpawn(), ['check-ignore', '--', ...pathsToCheck], {
|
|
cwd: resolvedPath,
|
|
windowsHide: true,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
|
|
let stdout = '';
|
|
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
child.on('close', () => resolve(stdout));
|
|
child.on('error', () => resolve(''));
|
|
});
|
|
|
|
result.split('\n').filter(Boolean).forEach((name) => {
|
|
const fullPath = path.join(resolvedPath, name.trim());
|
|
ignoredPaths.add(fullPath);
|
|
});
|
|
} catch {
|
|
}
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
const entries = await Promise.all(
|
|
dirents.map(async (dirent) => {
|
|
const entryPath = path.join(resolvedPath, dirent.name);
|
|
if (respectGitignore && ignoredPaths.has(entryPath)) {
|
|
return null;
|
|
}
|
|
|
|
let isDirectory = dirent.isDirectory();
|
|
const isSymbolicLink = dirent.isSymbolicLink();
|
|
|
|
if (!isDirectory && isSymbolicLink) {
|
|
try {
|
|
const linkStats = await fsPromises.stat(entryPath);
|
|
isDirectory = linkStats.isDirectory();
|
|
} catch {
|
|
isDirectory = false;
|
|
}
|
|
}
|
|
|
|
return {
|
|
name: dirent.name,
|
|
path: entryPath,
|
|
isDirectory,
|
|
isFile: dirent.isFile(),
|
|
isSymbolicLink,
|
|
};
|
|
})
|
|
);
|
|
|
|
return res.json({
|
|
path: resolvedPath,
|
|
entries: entries.filter(Boolean),
|
|
});
|
|
} catch (error) {
|
|
const err = error;
|
|
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
|
|
const isPlansPath = code === 'ENOENT' && (isPlansDirectory(resolvedPath) || isPlansDirectory(rawPath));
|
|
if (!isPlansPath) {
|
|
console.error('Failed to list directory:', error);
|
|
}
|
|
if (code === 'ENOENT') {
|
|
if (isPlansPath) {
|
|
return res.json({ path: resolvedPath || rawPath, entries: [] });
|
|
}
|
|
return res.status(404).json({ error: 'Directory not found' });
|
|
}
|
|
if (code === 'EACCES') {
|
|
return res.status(403).json({ error: 'Access to directory denied' });
|
|
}
|
|
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
|
|
}
|
|
});
|
|
};
|