add unrestricted filesystem browsing mode
This commit is contained in:
@@ -60,13 +60,39 @@ export interface FileSystemEntry {
|
||||
name: string
|
||||
/** Path relative to the CLI server root ("." represents the root itself). */
|
||||
path: string
|
||||
/** Absolute path when available (unrestricted listings). */
|
||||
absolutePath?: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
/** ISO timestamp of last modification when available. */
|
||||
modifiedAt?: string
|
||||
}
|
||||
|
||||
export type FileSystemListResponse = FileSystemEntry[]
|
||||
export type FileSystemScope = "restricted" | "unrestricted"
|
||||
export type FileSystemPathKind = "relative" | "absolute" | "drives"
|
||||
|
||||
export interface FileSystemListingMetadata {
|
||||
scope: FileSystemScope
|
||||
/** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */
|
||||
currentPath: string
|
||||
/** Optional parent path if navigation upward is allowed. */
|
||||
parentPath?: string
|
||||
/** Absolute path representing the root or origin point for this listing. */
|
||||
rootPath: string
|
||||
/** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */
|
||||
homePath: string
|
||||
/** Human-friendly label for the current path. */
|
||||
displayPath: string
|
||||
/** Indicates whether entry paths are relative, absolute, or represent drive roots. */
|
||||
pathKind: FileSystemPathKind
|
||||
}
|
||||
|
||||
export interface FileSystemListResponse {
|
||||
entries: FileSystemEntry[]
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
workspaceId: string
|
||||
|
||||
@@ -1,56 +1,196 @@
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { FileSystemEntry } from "../api-types"
|
||||
import {
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
WINDOWS_DRIVES_ROOT,
|
||||
} from "../api-types"
|
||||
|
||||
interface FileSystemBrowserOptions {
|
||||
rootDir: string
|
||||
unrestricted?: boolean
|
||||
}
|
||||
|
||||
interface DirectoryReadOptions {
|
||||
includeFiles: boolean
|
||||
formatPath: (entryName: string) => string
|
||||
formatAbsolutePath: (entryName: string) => string
|
||||
}
|
||||
|
||||
const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i))
|
||||
|
||||
export class FileSystemBrowser {
|
||||
private readonly root: string
|
||||
private readonly unrestricted: boolean
|
||||
private readonly homeDir: string
|
||||
private readonly isWindows: boolean
|
||||
|
||||
constructor(options: FileSystemBrowserOptions) {
|
||||
this.root = path.resolve(options.rootDir)
|
||||
this.unrestricted = Boolean(options.unrestricted)
|
||||
this.homeDir = os.homedir()
|
||||
this.isWindows = process.platform === "win32"
|
||||
}
|
||||
|
||||
list(relativePath: string, options: { depth?: number; includeFiles?: boolean } = {}): FileSystemEntry[] {
|
||||
const depth = options.depth ?? 2
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
if (depth < 1) {
|
||||
throw new Error("Depth must be at least 1")
|
||||
list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("Relative listing is unavailable when running with unrestricted root")
|
||||
}
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
return this.walk(normalizedPath, depth, includeFiles)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
return this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
}
|
||||
|
||||
private walk(relativePath: string, remainingDepth: number, includeFiles: boolean): FileSystemEntry[] {
|
||||
const resolved = this.toAbsolute(relativePath)
|
||||
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
||||
browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse {
|
||||
const includeFiles = options.includeFiles ?? true
|
||||
if (this.unrestricted) {
|
||||
return this.listUnrestricted(targetPath, includeFiles)
|
||||
}
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
return entries.flatMap<FileSystemEntry>((entry) => {
|
||||
const entryPath = path.join(relativePath, entry.name)
|
||||
const absolutePath = this.toAbsolute(entryPath)
|
||||
const stats = fs.statSync(absolutePath)
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
}
|
||||
const resolved = this.toRestrictedAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
}
|
||||
|
||||
const current: FileSystemEntry = {
|
||||
name: entry.name,
|
||||
path: this.normalizeRelativePath(entryPath),
|
||||
type: entry.isDirectory() ? "directory" : "file",
|
||||
size: entry.isDirectory() ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && remainingDepth > 1) {
|
||||
const nested = this.walk(entryPath, remainingDepth - 1, includeFiles)
|
||||
return [current, ...nested]
|
||||
}
|
||||
|
||||
if (!entry.isDirectory() && !includeFiles) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [current]
|
||||
private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const normalizedPath = this.normalizeRelativePath(relativePath)
|
||||
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
||||
const entries = this.readDirectoryEntries(absolutePath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "restricted",
|
||||
currentPath: normalizedPath,
|
||||
parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath),
|
||||
rootPath: this.root,
|
||||
homePath: this.homeDir,
|
||||
displayPath: this.resolveRestrictedAbsolute(normalizedPath),
|
||||
pathKind: "relative",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse {
|
||||
const resolvedPath = this.resolveUnrestrictedPath(targetPath)
|
||||
|
||||
if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) {
|
||||
return this.listWindowsDrives()
|
||||
}
|
||||
|
||||
const entries = this.readDirectoryEntries(resolvedPath, {
|
||||
includeFiles,
|
||||
formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName),
|
||||
})
|
||||
|
||||
const parentPath = this.getUnrestrictedParent(resolvedPath)
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: resolvedPath,
|
||||
parentPath,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: resolvedPath,
|
||||
pathKind: "absolute",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private listWindowsDrives(): FileSystemListResponse {
|
||||
if (!this.isWindows) {
|
||||
throw new Error("Drive listing is only supported on Windows hosts")
|
||||
}
|
||||
|
||||
const entries: FileSystemEntry[] = []
|
||||
for (const letter of WINDOWS_DRIVE_LETTERS) {
|
||||
const drivePath = `${letter}:\\`
|
||||
try {
|
||||
if (fs.existsSync(drivePath)) {
|
||||
entries.push({
|
||||
name: `${letter}:`,
|
||||
path: drivePath,
|
||||
absolutePath: drivePath,
|
||||
type: "directory",
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore inaccessible drives
|
||||
}
|
||||
}
|
||||
|
||||
// Provide a generic UNC root entry so users can navigate to network shares manually.
|
||||
entries.push({
|
||||
name: "UNC Network",
|
||||
path: "\\\\",
|
||||
absolutePath: "\\\\",
|
||||
type: "directory",
|
||||
})
|
||||
|
||||
const metadata: FileSystemListingMetadata = {
|
||||
scope: "unrestricted",
|
||||
currentPath: WINDOWS_DRIVES_ROOT,
|
||||
parentPath: undefined,
|
||||
rootPath: this.homeDir,
|
||||
homePath: this.homeDir,
|
||||
displayPath: "Drives",
|
||||
pathKind: "drives",
|
||||
}
|
||||
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
for (const entry of dirents) {
|
||||
if (!options.includeFiles && !entry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const absoluteEntryPath = path.join(directory, entry.name)
|
||||
let stats: fs.Stats
|
||||
try {
|
||||
stats = fs.statSync(absoluteEntryPath)
|
||||
} catch {
|
||||
// Skip entries we cannot stat (insufficient permissions, etc.)
|
||||
continue
|
||||
}
|
||||
|
||||
const isDirectory = entry.isDirectory()
|
||||
if (!options.includeFiles && !isDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
results.push({
|
||||
name: entry.name,
|
||||
path: options.formatPath(entry.name),
|
||||
absolutePath: options.formatAbsolutePath(entry.name),
|
||||
type: isDirectory ? "directory" : "file",
|
||||
size: isDirectory ? undefined : stats.size,
|
||||
modifiedAt: stats.mtime.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
private normalizeRelativePath(input: string | undefined) {
|
||||
@@ -67,16 +207,89 @@ export class FileSystemBrowser {
|
||||
return normalized === "" ? "." : normalized
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
const resolved = this.toAbsolute(relativePath)
|
||||
return fs.readFileSync(resolved, "utf-8")
|
||||
private buildRelativePath(parent: string, child: string) {
|
||||
if (!parent || parent === ".") {
|
||||
return this.normalizeRelativePath(child)
|
||||
}
|
||||
return this.normalizeRelativePath(`${parent}/${child}`)
|
||||
}
|
||||
|
||||
private toAbsolute(relativePath: string) {
|
||||
const target = path.resolve(this.root, relativePath)
|
||||
if (!target.startsWith(this.root)) {
|
||||
private resolveRestrictedAbsolute(relativePath: string) {
|
||||
return this.toRestrictedAbsolute(relativePath)
|
||||
}
|
||||
|
||||
private resolveRestrictedAbsoluteChild(parent: string, child: string) {
|
||||
const normalized = this.buildRelativePath(parent, child)
|
||||
return this.toRestrictedAbsolute(normalized)
|
||||
}
|
||||
|
||||
private toRestrictedAbsolute(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
const target = path.resolve(this.root, normalized)
|
||||
const relativeToRoot = path.relative(this.root, target)
|
||||
if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") {
|
||||
throw new Error("Access outside of root is not allowed")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private resolveUnrestrictedPath(input: string | undefined): string {
|
||||
if (!input || input === "." || input === "./") {
|
||||
return this.homeDir
|
||||
}
|
||||
|
||||
if (this.isWindows) {
|
||||
if (input === WINDOWS_DRIVES_ROOT) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
const normalized = path.win32.normalize(input)
|
||||
if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
return path.win32.resolve(this.homeDir, normalized)
|
||||
}
|
||||
|
||||
if (input.startsWith("/")) {
|
||||
return path.posix.normalize(input)
|
||||
}
|
||||
|
||||
return path.posix.resolve(this.homeDir, input)
|
||||
}
|
||||
|
||||
private resolveAbsoluteChild(parent: string, child: string) {
|
||||
if (this.isWindows) {
|
||||
return path.win32.normalize(path.win32.join(parent, child))
|
||||
}
|
||||
return path.posix.normalize(path.posix.join(parent, child))
|
||||
}
|
||||
|
||||
private getRestrictedParent(relativePath: string) {
|
||||
const normalized = this.normalizeRelativePath(relativePath)
|
||||
if (normalized === ".") {
|
||||
return undefined
|
||||
}
|
||||
const segments = normalized.split("/")
|
||||
segments.pop()
|
||||
return segments.length === 0 ? "." : segments.join("/")
|
||||
}
|
||||
|
||||
private getUnrestrictedParent(currentPath: string) {
|
||||
if (this.isWindows) {
|
||||
const normalized = path.win32.normalize(currentPath)
|
||||
const parsed = path.win32.parse(normalized)
|
||||
if (normalized === WINDOWS_DRIVES_ROOT) {
|
||||
return undefined
|
||||
}
|
||||
if (normalized === parsed.root) {
|
||||
return WINDOWS_DRIVES_ROOT
|
||||
}
|
||||
return path.win32.dirname(normalized)
|
||||
}
|
||||
|
||||
const normalized = path.posix.normalize(currentPath)
|
||||
if (normalized === "/") {
|
||||
return undefined
|
||||
}
|
||||
return path.posix.dirname(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface CliOptions {
|
||||
host: string
|
||||
rootDir: string
|
||||
configPath: string
|
||||
unrestrictedRoot: boolean
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
}
|
||||
@@ -34,7 +35,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
.version(packageJson.version, "-v, --version", "Show the CLI version")
|
||||
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
|
||||
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
|
||||
.addOption(new Option("--root <path>", "Workspace root directory").default(process.cwd()))
|
||||
.addOption(
|
||||
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
|
||||
)
|
||||
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
|
||||
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))
|
||||
.addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
|
||||
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
|
||||
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
|
||||
@@ -43,17 +48,22 @@ function parseCliOptions(argv: string[]): CliOptions {
|
||||
const parsed = program.opts<{
|
||||
host: string
|
||||
port: number
|
||||
root: string
|
||||
workspaceRoot?: string
|
||||
root?: string
|
||||
unrestrictedRoot?: boolean
|
||||
config: string
|
||||
logLevel?: string
|
||||
logDestination?: string
|
||||
}>()
|
||||
|
||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||
|
||||
return {
|
||||
port: parsed.port,
|
||||
host: parsed.host,
|
||||
rootDir: parsed.root,
|
||||
rootDir: resolvedRoot,
|
||||
configPath: parsed.config,
|
||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||
logLevel: parsed.logLevel,
|
||||
logDestination: parsed.logDestination,
|
||||
}
|
||||
@@ -86,7 +96,7 @@ async function main() {
|
||||
eventBus,
|
||||
logger: workspaceLogger,
|
||||
})
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir })
|
||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||
const instanceStore = new InstanceStore()
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
|
||||
@@ -8,18 +8,15 @@ interface RouteDeps {
|
||||
|
||||
const FilesystemQuerySchema = z.object({
|
||||
path: z.string().optional(),
|
||||
depth: z.coerce.number().int().min(1).max(10).default(2),
|
||||
includeFiles: z.coerce.boolean().default(true),
|
||||
includeFiles: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
const targetPath = query.path ?? "."
|
||||
|
||||
try {
|
||||
return deps.fileSystemBrowser.list(targetPath, {
|
||||
depth: query.depth,
|
||||
return deps.fileSystemBrowser.browse(query.path, {
|
||||
includeFiles: query.includeFiles,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user