807 lines
23 KiB
JavaScript
807 lines
23 KiB
JavaScript
import fs from "node:fs/promises";
|
|
import fsSync from "node:fs";
|
|
import path from "node:path";
|
|
import { applyTextPatch } from "./patch.js";
|
|
|
|
function assertNonEmptyString(value, label) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw new Error(`${label} must be a non-empty string`);
|
|
}
|
|
}
|
|
|
|
// MCP clients sometimes serialize an omitted root path as an empty string.
|
|
// Treat that spelling as the workspace root rather than making a harmless
|
|
// discovery request fail.
|
|
function normalizeRootPath(value) {
|
|
return typeof value === "string" && value.trim() === "" ? "." : value;
|
|
}
|
|
|
|
function normalizeWorkspaceSubpath(workspaceSubpath) {
|
|
if (!workspaceSubpath) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = String(workspaceSubpath).trim().replace(/\\/g, "/");
|
|
if (normalized === "") {
|
|
return null;
|
|
}
|
|
if (normalized.startsWith("/")) {
|
|
throw new Error("workspaceSubpath must be relative");
|
|
}
|
|
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
if (segments.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
for (const segment of segments) {
|
|
if (segment === "." || segment === "..") {
|
|
throw new Error("workspaceSubpath must not contain traversal segments");
|
|
}
|
|
if (!/^[A-Za-z0-9._-]+$/.test(segment)) {
|
|
throw new Error("workspaceSubpath contains unsupported characters");
|
|
}
|
|
}
|
|
|
|
return segments.join(path.sep);
|
|
}
|
|
|
|
function isWithinPath(targetPath, rootPath) {
|
|
const relative = path.relative(rootPath, targetPath);
|
|
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function normalizeClientPath(rootRecord, absolutePath) {
|
|
const relative = path.relative(rootRecord.resolved, absolutePath);
|
|
return (relative === "" ? "." : relative).split(path.sep).join("/");
|
|
}
|
|
|
|
function looksBinary(buffer) {
|
|
return buffer.includes(0);
|
|
}
|
|
|
|
function findAllIndexes(text, query, caseSensitive) {
|
|
const haystack = caseSensitive ? text : text.toLowerCase();
|
|
const needle = caseSensitive ? query : query.toLowerCase();
|
|
const indexes = [];
|
|
let fromIndex = 0;
|
|
|
|
while (fromIndex <= haystack.length) {
|
|
const matchIndex = haystack.indexOf(needle, fromIndex);
|
|
if (matchIndex === -1) {
|
|
break;
|
|
}
|
|
|
|
indexes.push(matchIndex);
|
|
fromIndex = matchIndex + Math.max(needle.length, 1);
|
|
}
|
|
|
|
return indexes;
|
|
}
|
|
|
|
function getPathType(stats) {
|
|
if (stats.isDirectory()) {
|
|
return "directory";
|
|
}
|
|
if (stats.isFile()) {
|
|
return "file";
|
|
}
|
|
if (stats.isSymbolicLink()) {
|
|
return "symlink";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function decodeBase64(contentBase64) {
|
|
assertNonEmptyString(contentBase64, "contentBase64");
|
|
|
|
const normalized = contentBase64.replace(/\s+/g, "");
|
|
if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
|
|
throw new Error("contentBase64 must be valid base64");
|
|
}
|
|
|
|
return Buffer.from(normalized, "base64");
|
|
}
|
|
|
|
async function readTextFile(absolutePath) {
|
|
const buffer = await fs.readFile(absolutePath);
|
|
|
|
if (looksBinary(buffer)) {
|
|
throw new Error("Binary files are not supported");
|
|
}
|
|
|
|
return buffer.toString("utf8");
|
|
}
|
|
|
|
export class WorkspaceService {
|
|
constructor({ roots }) {
|
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
throw new Error("At least one workspace root is required");
|
|
}
|
|
|
|
this.roots = roots.map((rootPath) => {
|
|
assertNonEmptyString(rootPath, "root");
|
|
const resolved = path.resolve(rootPath);
|
|
|
|
if (!fsSync.existsSync(resolved)) {
|
|
throw new Error(`Workspace root does not exist: ${resolved}`);
|
|
}
|
|
if (!fsSync.statSync(resolved).isDirectory()) {
|
|
throw new Error(`Workspace root must be a directory: ${resolved}`);
|
|
}
|
|
|
|
return {
|
|
configured: rootPath,
|
|
resolved,
|
|
real: fsSync.realpathSync(resolved)
|
|
};
|
|
});
|
|
}
|
|
|
|
getPrimaryRoot() {
|
|
return this.roots[0];
|
|
}
|
|
|
|
ensureWorkspace(context = {}) {
|
|
const rootRecord = this.getScopedRoot(null, context, { createIfMissing: true });
|
|
return {
|
|
root: rootRecord.resolved,
|
|
workspaceSubpath: rootRecord.workspaceSubpath
|
|
};
|
|
}
|
|
|
|
getScopedRoot(rootHint, context, { createIfMissing = false } = {}) {
|
|
const baseRootRecord = this.selectRoot(rootHint);
|
|
const workspaceSubpath = normalizeWorkspaceSubpath(context?.workspaceSubpath ?? null);
|
|
|
|
let resolved = baseRootRecord.resolved;
|
|
if (workspaceSubpath) {
|
|
resolved = path.resolve(resolved, workspaceSubpath);
|
|
}
|
|
|
|
if (!isWithinPath(resolved, baseRootRecord.resolved)) {
|
|
throw new Error("Resolved session root escapes the allowed workspace root");
|
|
}
|
|
|
|
if (createIfMissing && !fsSync.existsSync(resolved)) {
|
|
fsSync.mkdirSync(resolved, { recursive: true });
|
|
}
|
|
|
|
const real = fsSync.existsSync(resolved)
|
|
? fsSync.realpathSync(resolved)
|
|
: resolved;
|
|
|
|
return {
|
|
configured: baseRootRecord.configured,
|
|
resolved,
|
|
real,
|
|
baseRoot: baseRootRecord.resolved,
|
|
sessionId: context?.sessionId ?? null,
|
|
workspaceSubpath: workspaceSubpath
|
|
};
|
|
}
|
|
|
|
selectRoot(rootHint) {
|
|
if (!rootHint) {
|
|
return this.getPrimaryRoot();
|
|
}
|
|
|
|
const matched = this.roots.find(
|
|
(rootRecord) =>
|
|
rootHint === rootRecord.configured ||
|
|
rootHint === rootRecord.resolved ||
|
|
rootHint === rootRecord.real
|
|
);
|
|
|
|
if (!matched) {
|
|
throw new Error(`Unknown root: ${rootHint}`);
|
|
}
|
|
|
|
return matched;
|
|
}
|
|
|
|
resolvePath(inputPath, { root, context, createSessionRoot = false } = {}) {
|
|
inputPath = normalizeRootPath(inputPath);
|
|
assertNonEmptyString(inputPath, "path");
|
|
|
|
if (inputPath.includes("\0") || path.isAbsolute(inputPath)) {
|
|
throw new Error("Path must be a relative path");
|
|
}
|
|
|
|
if (root) {
|
|
throw new Error("Selecting a workspace root from a tool call is not allowed");
|
|
}
|
|
|
|
const rootRecord = this.getScopedRoot(null, context, { createIfMissing: createSessionRoot });
|
|
const absolutePath = path.resolve(rootRecord.resolved, inputPath);
|
|
|
|
if (!isWithinPath(absolutePath, rootRecord.resolved)) {
|
|
throw new Error(`Path escapes allowed workspace root: ${inputPath}`);
|
|
}
|
|
|
|
return {
|
|
rootRecord,
|
|
absolutePath,
|
|
clientPath: normalizeClientPath(rootRecord, absolutePath)
|
|
};
|
|
}
|
|
|
|
ensureNotRootPath(absolutePath, rootRecord, action) {
|
|
if (path.resolve(absolutePath) === rootRecord.resolved) {
|
|
throw new Error(`Refusing to ${action} the workspace root`);
|
|
}
|
|
}
|
|
|
|
async validatePathChain(targetPath, rootRecord) {
|
|
if (!isWithinPath(targetPath, rootRecord.resolved)) {
|
|
throw new Error("Resolved path escapes the allowed workspace root");
|
|
}
|
|
|
|
const relativePath = path.relative(rootRecord.resolved, targetPath);
|
|
|
|
if (relativePath === "") {
|
|
return {
|
|
firstMissingPath: null
|
|
};
|
|
}
|
|
|
|
const segments = relativePath.split(path.sep).filter(Boolean);
|
|
let currentPath = rootRecord.resolved;
|
|
|
|
for (const segment of segments) {
|
|
const nextPath = path.join(currentPath, segment);
|
|
|
|
if (!fsSync.existsSync(nextPath)) {
|
|
return {
|
|
firstMissingPath: nextPath
|
|
};
|
|
}
|
|
|
|
const stats = await fs.lstat(nextPath);
|
|
if (stats.isSymbolicLink()) {
|
|
throw new Error(`Symlink path segments are not allowed: ${normalizeClientPath(rootRecord, nextPath)}`);
|
|
}
|
|
|
|
const realPath = await fs.realpath(nextPath);
|
|
if (!isWithinPath(realPath, rootRecord.real)) {
|
|
throw new Error("Resolved path escapes the allowed workspace root");
|
|
}
|
|
|
|
currentPath = nextPath;
|
|
}
|
|
|
|
return {
|
|
firstMissingPath: null
|
|
};
|
|
}
|
|
|
|
async assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink = false } = {}) {
|
|
const chainTarget = absolutePath === rootRecord.resolved
|
|
? absolutePath
|
|
: path.dirname(absolutePath);
|
|
await this.validatePathChain(chainTarget, rootRecord);
|
|
|
|
const stats = await fs.lstat(absolutePath);
|
|
if (stats.isSymbolicLink()) {
|
|
if (!allowSymlink) {
|
|
throw new Error("Symlink targets are not allowed for this operation");
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
const realTarget = await fs.realpath(absolutePath);
|
|
if (!isWithinPath(realTarget, rootRecord.real)) {
|
|
throw new Error("Resolved path escapes the allowed workspace root");
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
async prepareDestination(absolutePath, rootRecord, { createParents = true } = {}) {
|
|
const parentPath = path.dirname(absolutePath);
|
|
const { firstMissingPath } = await this.validatePathChain(parentPath, rootRecord);
|
|
|
|
if (firstMissingPath && !createParents) {
|
|
throw new Error(`Parent directory does not exist: ${normalizeClientPath(rootRecord, firstMissingPath)}`);
|
|
}
|
|
|
|
if (createParents) {
|
|
await fs.mkdir(parentPath, { recursive: true });
|
|
}
|
|
|
|
await this.validatePathChain(parentPath, rootRecord);
|
|
}
|
|
|
|
async writeBufferFile({ path: targetPath, root, buffer, createParents = true, context = {} }) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
|
|
root,
|
|
context,
|
|
createSessionRoot: true
|
|
});
|
|
const existed = fsSync.existsSync(absolutePath);
|
|
|
|
if (existed) {
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
if (!stats.isFile()) {
|
|
throw new Error("Path is not a regular file");
|
|
}
|
|
} else {
|
|
await this.prepareDestination(absolutePath, rootRecord, { createParents });
|
|
}
|
|
|
|
await fs.writeFile(absolutePath, buffer);
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
bytesWritten: buffer.length,
|
|
created: !existed
|
|
};
|
|
}
|
|
|
|
async listFiles({ path: targetPath = ".", root, recursive = false, maxEntries = 500 } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath);
|
|
await this.validatePathChain(chainTarget, rootRecord);
|
|
if (!fsSync.existsSync(absolutePath)) {
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
exists: false,
|
|
entries: [],
|
|
truncated: false
|
|
};
|
|
}
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
const entries = [];
|
|
const queue = [{ absolutePath, clientPath }];
|
|
let truncated = false;
|
|
|
|
if (!stats.isDirectory()) {
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
exists: true,
|
|
entries: [
|
|
{
|
|
path: clientPath,
|
|
type: getPathType(stats),
|
|
size: stats.size,
|
|
mtimeMs: stats.mtimeMs
|
|
}
|
|
],
|
|
truncated
|
|
};
|
|
}
|
|
|
|
while (queue.length > 0 && entries.length < maxEntries) {
|
|
const current = queue.shift();
|
|
const currentEntries = await fs.readdir(current.absolutePath, { withFileTypes: true });
|
|
currentEntries.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
for (const dirent of currentEntries) {
|
|
const childAbsolute = path.join(current.absolutePath, dirent.name);
|
|
const childClient = current.clientPath === "."
|
|
? dirent.name
|
|
: `${current.clientPath}/${dirent.name}`;
|
|
const childStats = await fs.lstat(childAbsolute);
|
|
|
|
entries.push({
|
|
path: childClient,
|
|
type: dirent.isDirectory()
|
|
? "directory"
|
|
: dirent.isFile()
|
|
? "file"
|
|
: dirent.isSymbolicLink()
|
|
? "symlink"
|
|
: "other",
|
|
size: childStats.size,
|
|
mtimeMs: childStats.mtimeMs
|
|
});
|
|
|
|
if (entries.length >= maxEntries) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
if (recursive && dirent.isDirectory() && !dirent.isSymbolicLink()) {
|
|
queue.push({
|
|
absolutePath: childAbsolute,
|
|
clientPath: childClient
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
exists: true,
|
|
entries,
|
|
truncated
|
|
};
|
|
}
|
|
|
|
async readFile({ path: targetPath, root } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
|
|
if (!stats.isFile()) {
|
|
throw new Error("Path is not a file");
|
|
}
|
|
|
|
const content = await readTextFile(absolutePath);
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
size: stats.size,
|
|
content
|
|
};
|
|
}
|
|
|
|
async readBinaryFile({ path: targetPath, root } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
|
|
if (!stats.isFile()) {
|
|
throw new Error("Path is not a file");
|
|
}
|
|
|
|
const content = await fs.readFile(absolutePath);
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
size: stats.size,
|
|
encoding: "base64",
|
|
contentBase64: content.toString("base64")
|
|
};
|
|
}
|
|
|
|
async readFiles({ paths, root } = {}, context = {}) {
|
|
if (!Array.isArray(paths) || paths.length === 0) {
|
|
throw new Error("paths must be a non-empty array");
|
|
}
|
|
|
|
const files = [];
|
|
for (const targetPath of paths) {
|
|
files.push(await this.readFile({ path: targetPath, root }, context));
|
|
}
|
|
|
|
return { files };
|
|
}
|
|
|
|
async searchText({ path: targetPath = ".", root, query, maxMatches = 200, caseSensitive = false } = {}, context = {}) {
|
|
assertNonEmptyString(query, "query");
|
|
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
|
|
const matches = [];
|
|
const queue = [{ absolutePath, clientPath }];
|
|
let truncated = false;
|
|
|
|
while (queue.length > 0 && matches.length < maxMatches) {
|
|
const current = queue.shift();
|
|
const currentStats = await fs.lstat(current.absolutePath);
|
|
|
|
if (currentStats.isDirectory()) {
|
|
const children = await fs.readdir(current.absolutePath, { withFileTypes: true });
|
|
children.sort((left, right) => left.name.localeCompare(right.name));
|
|
|
|
for (const dirent of children) {
|
|
if (dirent.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
|
|
const childAbsolute = path.join(current.absolutePath, dirent.name);
|
|
const childClient = current.clientPath === "."
|
|
? dirent.name
|
|
: `${current.clientPath}/${dirent.name}`;
|
|
queue.push({ absolutePath: childAbsolute, clientPath: childClient });
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (!currentStats.isFile()) {
|
|
continue;
|
|
}
|
|
|
|
const buffer = await fs.readFile(current.absolutePath);
|
|
if (looksBinary(buffer)) {
|
|
continue;
|
|
}
|
|
|
|
const text = buffer.toString("utf8");
|
|
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
|
|
|
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
const indexes = findAllIndexes(lines[lineIndex], query, caseSensitive);
|
|
|
|
for (const columnIndex of indexes) {
|
|
matches.push({
|
|
path: current.clientPath,
|
|
line: lineIndex + 1,
|
|
column: columnIndex + 1,
|
|
preview: lines[lineIndex]
|
|
});
|
|
|
|
if (matches.length >= maxMatches) {
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (truncated) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
query,
|
|
matches,
|
|
truncated
|
|
};
|
|
}
|
|
|
|
async writeFile({ path: targetPath, root, content, createParents = true } = {}, context = {}) {
|
|
if (typeof content !== "string") {
|
|
throw new Error("content must be a string");
|
|
}
|
|
|
|
return this.writeBufferFile({
|
|
path: targetPath,
|
|
root,
|
|
buffer: Buffer.from(content, "utf8"),
|
|
createParents,
|
|
context
|
|
});
|
|
}
|
|
|
|
async writeBinaryFile({ path: targetPath, root, contentBase64, createParents = true } = {}, context = {}) {
|
|
return this.writeBufferFile({
|
|
path: targetPath,
|
|
root,
|
|
buffer: decodeBase64(contentBase64),
|
|
createParents,
|
|
context
|
|
});
|
|
}
|
|
|
|
async applyPatch({ path: targetPath, root, patch } = {}, context = {}) {
|
|
assertNonEmptyString(patch, "patch");
|
|
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
|
|
root,
|
|
context,
|
|
createSessionRoot: true
|
|
});
|
|
const fileExists = fsSync.existsSync(absolutePath);
|
|
|
|
if (fileExists) {
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
if (!stats.isFile()) {
|
|
throw new Error("Path is not a regular file");
|
|
}
|
|
} else {
|
|
await this.prepareDestination(absolutePath, rootRecord, { createParents: false });
|
|
}
|
|
|
|
const originalText = fileExists ? await readTextFile(absolutePath) : "";
|
|
const patchResult = applyTextPatch({
|
|
originalText,
|
|
patchText: patch,
|
|
expectedPath: clientPath,
|
|
fileExists
|
|
});
|
|
|
|
if (patchResult.action === "delete") {
|
|
await fs.rm(absolutePath, { force: false });
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
action: "delete",
|
|
hunksApplied: patchResult.hunksApplied
|
|
};
|
|
}
|
|
|
|
await fs.writeFile(absolutePath, patchResult.updatedText, "utf8");
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
action: patchResult.action,
|
|
bytesWritten: Buffer.byteLength(patchResult.updatedText, "utf8"),
|
|
hunksApplied: patchResult.hunksApplied
|
|
};
|
|
}
|
|
|
|
async makeDirectory({ path: targetPath, root } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
|
|
root,
|
|
context,
|
|
createSessionRoot: true
|
|
});
|
|
const existed = fsSync.existsSync(absolutePath);
|
|
|
|
if (existed) {
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
if (!stats.isDirectory()) {
|
|
throw new Error("Path exists and is not a directory");
|
|
}
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
created: false
|
|
};
|
|
}
|
|
|
|
await this.prepareDestination(absolutePath, rootRecord, { createParents: true });
|
|
await fs.mkdir(absolutePath, { recursive: true });
|
|
await this.assertSafeExistingPath(absolutePath, rootRecord);
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
created: true
|
|
};
|
|
}
|
|
|
|
async deletePath({ path: targetPath, root, recursive = false, allowMissing = false } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
this.ensureNotRootPath(absolutePath, rootRecord, "delete");
|
|
|
|
if (!fsSync.existsSync(absolutePath)) {
|
|
if (allowMissing) {
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
deleted: false,
|
|
missing: true
|
|
};
|
|
}
|
|
|
|
throw new Error("Path does not exist");
|
|
}
|
|
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true });
|
|
if (stats.isDirectory() && !recursive) {
|
|
throw new Error("Refusing to delete a directory without recursive=true");
|
|
}
|
|
|
|
await fs.rm(absolutePath, { recursive: stats.isDirectory(), force: false });
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
deleted: true,
|
|
type: getPathType(stats)
|
|
};
|
|
}
|
|
|
|
async movePath({ fromPath, toPath, root, overwrite = false, createParents = true } = {}, context = {}) {
|
|
assertNonEmptyString(fromPath, "fromPath");
|
|
assertNonEmptyString(toPath, "toPath");
|
|
|
|
const source = this.resolvePath(fromPath, { root, context });
|
|
const destination = this.resolvePath(toPath, {
|
|
root,
|
|
context,
|
|
createSessionRoot: true
|
|
});
|
|
|
|
this.ensureNotRootPath(source.absolutePath, source.rootRecord, "move");
|
|
|
|
if (source.absolutePath === destination.absolutePath) {
|
|
return {
|
|
fromRoot: source.rootRecord.resolved,
|
|
fromPath: source.clientPath,
|
|
toRoot: destination.rootRecord.resolved,
|
|
toPath: destination.clientPath,
|
|
moved: false
|
|
};
|
|
}
|
|
|
|
const sourceStats = await this.assertSafeExistingPath(source.absolutePath, source.rootRecord, {
|
|
allowSymlink: true
|
|
});
|
|
|
|
if (
|
|
sourceStats.isDirectory() &&
|
|
isWithinPath(destination.absolutePath, source.absolutePath)
|
|
) {
|
|
throw new Error("Cannot move a directory into itself");
|
|
}
|
|
|
|
if (fsSync.existsSync(destination.absolutePath)) {
|
|
const destinationStats = await this.assertSafeExistingPath(
|
|
destination.absolutePath,
|
|
destination.rootRecord,
|
|
{ allowSymlink: true }
|
|
);
|
|
|
|
if (!overwrite) {
|
|
throw new Error("Destination already exists");
|
|
}
|
|
|
|
this.ensureNotRootPath(destination.absolutePath, destination.rootRecord, "overwrite");
|
|
await fs.rm(destination.absolutePath, {
|
|
recursive: destinationStats.isDirectory(),
|
|
force: false
|
|
});
|
|
} else {
|
|
await this.prepareDestination(destination.absolutePath, destination.rootRecord, { createParents });
|
|
}
|
|
|
|
try {
|
|
await fs.rename(source.absolutePath, destination.absolutePath);
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
|
|
throw new Error("Cross-device moves are not supported");
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
fromRoot: source.rootRecord.resolved,
|
|
fromPath: source.clientPath,
|
|
toRoot: destination.rootRecord.resolved,
|
|
toPath: destination.clientPath,
|
|
moved: true,
|
|
overwritten: overwrite
|
|
};
|
|
}
|
|
|
|
async renamePath({ path: targetPath, root, newName, overwrite = false } = {}, context = {}) {
|
|
assertNonEmptyString(newName, "newName");
|
|
|
|
if (
|
|
newName === "." ||
|
|
newName === ".." ||
|
|
newName.includes("/") ||
|
|
newName.includes("\\") ||
|
|
newName.includes(path.sep)
|
|
) {
|
|
throw new Error("newName must be a plain file or directory name");
|
|
}
|
|
|
|
const source = this.resolvePath(targetPath, { root, context });
|
|
const destinationPath = path.join(path.dirname(source.absolutePath), newName);
|
|
|
|
return this.movePath({
|
|
fromPath: source.clientPath,
|
|
toPath: path.relative(source.rootRecord.resolved, destinationPath),
|
|
overwrite,
|
|
createParents: false
|
|
}, context);
|
|
}
|
|
|
|
async fileInfo({ path: targetPath, root } = {}, context = {}) {
|
|
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
|
|
const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath);
|
|
await this.validatePathChain(chainTarget, rootRecord);
|
|
if (!fsSync.existsSync(absolutePath)) {
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
exists: false
|
|
};
|
|
}
|
|
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true });
|
|
|
|
return {
|
|
root: rootRecord.resolved,
|
|
path: clientPath,
|
|
exists: true,
|
|
type: getPathType(stats),
|
|
size: stats.size,
|
|
mtimeMs: stats.mtimeMs,
|
|
ctimeMs: stats.ctimeMs
|
|
};
|
|
}
|
|
}
|