- Copy complete source code packages from original CodeNomad project - Add root package.json with npm workspace configuration - Include electron-app, server, ui, tauri-app, and opencode-config packages - Fix Launch-Windows.bat and Launch-Dev-Windows.bat to work with correct npm scripts - Fix Launch-Unix.sh to work with correct npm scripts - Launchers now correctly call npm run dev:electron which launches Electron app
296 lines
9.2 KiB
TypeScript
296 lines
9.2 KiB
TypeScript
import fs from "fs"
|
|
import os from "os"
|
|
import path from "path"
|
|
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 = ".", 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)
|
|
const absolutePath = this.toRestrictedAbsolute(normalizedPath)
|
|
return this.readDirectoryEntries(absolutePath, {
|
|
includeFiles,
|
|
formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
|
|
formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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) {
|
|
if (!input || input === "." || input === "./" || input === "/") {
|
|
return "."
|
|
}
|
|
let normalized = input.replace(/\\+/g, "/")
|
|
if (normalized.startsWith("./")) {
|
|
normalized = normalized.replace(/^\.\/+/, "")
|
|
}
|
|
if (normalized.startsWith("/")) {
|
|
normalized = normalized.replace(/^\/+/g, "")
|
|
}
|
|
return normalized === "" ? "." : normalized
|
|
}
|
|
|
|
private buildRelativePath(parent: string, child: string) {
|
|
if (!parent || parent === ".") {
|
|
return this.normalizeRelativePath(child)
|
|
}
|
|
return this.normalizeRelativePath(`${parent}/${child}`)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|