ds-review-bot round 5: '\\' and '\\server' satisfy win32.isAbsolute and the previous two-separator test, yet resolve() collapses them to drive-relative roots. The UNC arm now requires server and share components; incomplete prefixes reject with the business codes, covered per-platform.
150 lines
6.7 KiB
TypeScript
150 lines
6.7 KiB
TypeScript
/**
|
|
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
|
|
* with the `browse` capability — one-level directory listing and child-directory
|
|
* creation over the host filesystem via Node's stdlib (which already carries
|
|
* the per-OS adaptation). Nothing renders on the host display, so this backend
|
|
* serves remote clients the dialog backend cannot. Policy decisions (hidden
|
|
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
|
|
* recorded in the directory-picker seam Agent Note.
|
|
* @module @deepseek-ai/dsh-host-directory-picker-browse
|
|
*/
|
|
|
|
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
import { homedir } from 'node:os'
|
|
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
|
|
import {
|
|
DirectoryPicker, DirectoryPickerError,
|
|
} from '@deepseek-ai/dsh-host-directory-picker'
|
|
import type {
|
|
DirectoryEntry, DirectoryListing, DirectoryPickerCapability,
|
|
} from '@deepseek-ai/dsh-host-directory-picker'
|
|
|
|
/**
|
|
* Ancestor chain from the filesystem root to `target` inclusive — the
|
|
* breadcrumb rows of a listing, every one a jump target.
|
|
*/
|
|
function ancestryCrumbs(target: string): DirectoryEntry[] {
|
|
const crumbs: DirectoryEntry[] = []
|
|
let current = target
|
|
for (;;) {
|
|
const parent = dirname(current)
|
|
// basename of a root is '' — label the root crumb by its full path ('/', 'C:\').
|
|
crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false })
|
|
if (parent === current) return crumbs
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True when the path names one fixed filesystem location regardless of
|
|
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
|
|
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
|
|
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
|
|
* pass `isAbsolute` yet still resolve against the process's current drive.
|
|
* @param path - candidate path.
|
|
* @param platform - replaces `process.platform` for deterministic tests.
|
|
* @returns whether the path is fully qualified on the platform.
|
|
*/
|
|
export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
|
|
return platform === 'win32'
|
|
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
|
|
: posix.isAbsolute(path)
|
|
}
|
|
|
|
/** Message text of an unknown thrown value. */
|
|
function messageOf(error: unknown): string {
|
|
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
/**
|
|
* One listing row for a dirent, following symlinks to directories; null for
|
|
* non-directories and broken/cyclic links (skipped silently — the browser
|
|
* shows what can be entered, and a broken link cannot).
|
|
*/
|
|
async function directoryRow(parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean): Promise<DirectoryEntry | null> {
|
|
const path = join(parent, name)
|
|
let enterable = isDirectory
|
|
if (!enterable && isSymbolicLink) {
|
|
try {
|
|
enterable = (await stat(path)).isDirectory()
|
|
} catch {
|
|
// Broken or cyclic symlink: stat is the probe, failure means "not enterable".
|
|
return null
|
|
}
|
|
}
|
|
if (!enterable) return null
|
|
// POSIX hidden convention; Windows' hidden attribute is not exposed by
|
|
// dirents (Known Limitations). The client owns whether hidden rows show.
|
|
return { name, path, hidden: name.startsWith('.') }
|
|
}
|
|
|
|
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
|
|
export default class BrowseDirectoryPicker extends DirectoryPicker {
|
|
private readonly browseCapability: DirectoryPickerCapability = {
|
|
kind: 'browse',
|
|
list: path => this.list(path),
|
|
createDirectory: (path, name) => this.createDirectory(path, name),
|
|
}
|
|
|
|
/**
|
|
* The browse interaction capability.
|
|
* @returns the stable `browse` capability object.
|
|
*/
|
|
capability(): DirectoryPickerCapability {
|
|
return this.browseCapability
|
|
}
|
|
|
|
private async list(path?: string): Promise<DirectoryListing> {
|
|
const home = homedir()
|
|
// The seam contract takes fully qualified paths only; resolve() would
|
|
// silently rebase a relative or empty wire value under the host process
|
|
// cwd (or, for rooted drive-less Windows forms, its current drive).
|
|
if (path !== undefined && !fullyQualified(path)) {
|
|
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
|
|
}
|
|
const target = resolve(path ?? home)
|
|
let names: { name: string; isDirectory: boolean; isSymbolicLink: boolean }[]
|
|
try {
|
|
const dirents = await readdir(target, { withFileTypes: true })
|
|
names = dirents.map(dirent => ({
|
|
name: dirent.name,
|
|
isDirectory: dirent.isDirectory(),
|
|
isSymbolicLink: dirent.isSymbolicLink(),
|
|
}))
|
|
} catch (error: unknown) {
|
|
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
|
|
}
|
|
const rows = await Promise.all(names.map(entry => directoryRow(target, entry.name, entry.isDirectory, entry.isSymbolicLink)))
|
|
const entries = rows.filter((row): row is DirectoryEntry => row !== null)
|
|
.sort((a, b) => a.name.localeCompare(b.name))
|
|
return { path: target, home, crumbs: ancestryCrumbs(target), entries }
|
|
}
|
|
|
|
private async createDirectory(path: string, name: string): Promise<string> {
|
|
// Same fully-qualified fence as list: never rebase a parent under the
|
|
// cwd or the current drive.
|
|
if (!fullyQualified(path)) {
|
|
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
|
|
}
|
|
const parent = resolve(path)
|
|
// The backend owns segment validation (the wire schema also refuses these,
|
|
// but direct service consumers must hit the same fence).
|
|
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
|
|
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
|
|
}
|
|
const target = join(parent, name)
|
|
try {
|
|
// Non-recursive: the parent is the directory the browser is showing, so
|
|
// a missing parent is a real failure, not a level to invent.
|
|
await mkdir(target)
|
|
return target
|
|
} catch (error: unknown) {
|
|
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
|
|
throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
|
|
}
|
|
throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`)
|
|
}
|
|
}
|
|
}
|