dsh-explorer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fs-tree.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { readdir, stat } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { ExplorerError } from './wire.ts'
4
+
5
+ export interface CompactSegment {
6
+ name: string
7
+ path: string
8
+ }
9
+
10
+ export interface FsEntry {
11
+ name: string
12
+ path: string
13
+ isDir: boolean
14
+ hidden: boolean
15
+ isSymlink: boolean
16
+ broken: boolean
17
+ compact?: boolean
18
+ compactName?: string
19
+ compactPath?: string
20
+ compactChain?: CompactSegment[]
21
+ }
22
+
23
+ export interface FsListing {
24
+ path: string
25
+ entries: FsEntry[]
26
+ truncated: boolean
27
+ }
28
+
29
+ export function compareEntries(a: FsEntry, b: FsEntry): number {
30
+ if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
31
+ const la = a.name.toLowerCase()
32
+ const lb = b.name.toLowerCase()
33
+ if (la !== lb) return la < lb ? -1 : 1
34
+ if (a.name === b.name) return 0
35
+ return a.name < b.name ? -1 : 1
36
+ }
37
+
38
+ const directoryCache = new Map<string, { at: number; listing: FsListing }>()
39
+ const DIRECTORY_CACHE_TTL_MS = 300
40
+
41
+ export function invalidateDirectoryCache(_dir?: string): void {
42
+ directoryCache.clear()
43
+ }
44
+
45
+ export async function listDirectory(dirPath: string, maxEntries = 2000): Promise<FsListing> {
46
+ const now = Date.now()
47
+ const key = `${dirPath}\0${maxEntries}`
48
+ const cached = directoryCache.get(key)
49
+ if (cached && now - cached.at < DIRECTORY_CACHE_TTL_MS) {
50
+ return cached.listing
51
+ }
52
+
53
+ let dirEnts
54
+ try {
55
+ dirEnts = await readdir(dirPath, { withFileTypes: true })
56
+ } catch (err: unknown) {
57
+ const message = err instanceof Error ? err.message : String(err)
58
+ throw new ExplorerError('not-found', `cannot read directory "${dirPath}": ${message}`, 404)
59
+ }
60
+
61
+ const entries: FsEntry[] = []
62
+ const truncated = dirEnts.length > maxEntries
63
+ const slice = truncated ? dirEnts.slice(0, maxEntries) : dirEnts
64
+
65
+ for (const dirent of slice) {
66
+ const name = dirent.name
67
+ const fullPath = join(dirPath, name)
68
+ const isSymlink = dirent.isSymbolicLink()
69
+ let isDir = dirent.isDirectory()
70
+ let broken = false
71
+
72
+ if (isSymlink) {
73
+ try {
74
+ const targetStat = await stat(fullPath)
75
+ isDir = targetStat.isDirectory()
76
+ } catch {
77
+ broken = true
78
+ }
79
+ }
80
+
81
+ const hidden = name.startsWith('.')
82
+ let compact = false
83
+ let compactName: string | undefined = undefined
84
+ let compactPath: string | undefined = undefined
85
+ let compactChain: CompactSegment[] | undefined = undefined
86
+
87
+ // If it's a directory, resolve single-child directory chains for compact folding
88
+ if (isDir && !isSymlink) {
89
+ try {
90
+ let currPath = fullPath
91
+ const chain: CompactSegment[] = [{ name, path: fullPath }]
92
+ let depth = 0
93
+ while (depth < 6) {
94
+ const sub = await readdir(currPath, { withFileTypes: true })
95
+ const visibleSub = sub.filter((s) => !s.name.startsWith('.'))
96
+ if (visibleSub.length === 1 && visibleSub[0]!.isDirectory() && !visibleSub[0]!.isSymbolicLink()) {
97
+ const childName = visibleSub[0]!.name
98
+ currPath = join(currPath, childName)
99
+ chain.push({ name: childName, path: currPath })
100
+ depth++
101
+ } else {
102
+ break
103
+ }
104
+ }
105
+ if (chain.length > 1) {
106
+ compact = true
107
+ compactName = chain.map((c) => c.name).join('/')
108
+ compactPath = currPath
109
+ compactChain = chain
110
+ }
111
+ } catch {
112
+ // Ignore read errors for child probe
113
+ }
114
+ }
115
+
116
+ entries.push({
117
+ name,
118
+ path: fullPath,
119
+ isDir,
120
+ hidden,
121
+ isSymlink,
122
+ broken,
123
+ compact,
124
+ compactName,
125
+ compactPath,
126
+ compactChain,
127
+ })
128
+ }
129
+
130
+ entries.sort(compareEntries)
131
+
132
+ const listing: FsListing = {
133
+ path: dirPath,
134
+ entries,
135
+ truncated,
136
+ }
137
+
138
+ directoryCache.set(key, { at: now, listing })
139
+ return listing
140
+ }
141
+
142
+ export async function listDirectoryBatch(paths: string[], maxEntriesPerDir = 1000): Promise<Record<string, FsListing>> {
143
+ const result: Record<string, FsListing> = {}
144
+ await Promise.all(
145
+ paths.map(async (p) => {
146
+ try {
147
+ result[p] = await listDirectory(p, maxEntriesPerDir)
148
+ } catch {
149
+ // Individual directory failures don't fail the whole batch
150
+ }
151
+ })
152
+ )
153
+ return result
154
+ }
155
+
@@ -0,0 +1,179 @@
1
+ import { watch, type FSWatcher } from 'node:fs'
2
+ import type { IncomingMessage, ServerResponse } from 'node:http'
3
+ import type { Duplex } from 'node:stream'
4
+ import { invalidateDirectoryCache } from './fs-tree.ts'
5
+
6
+ interface Subscriber {
7
+ send: (data: unknown) => void
8
+ close: () => void
9
+ }
10
+
11
+ interface DirWatcher {
12
+ watcher: FSWatcher
13
+ subscribers: Set<Subscriber>
14
+ }
15
+
16
+ const dirWatchers = new Map<string, DirWatcher>()
17
+
18
+ function normalizeDirPath(d: string): string {
19
+ return d.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
20
+ }
21
+
22
+ function subscribeToDir(dir: string, subscriber: Subscriber): () => void {
23
+ const norm = normalizeDirPath(dir)
24
+ let entry = dirWatchers.get(norm)
25
+ if (!entry) {
26
+ try {
27
+ const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
28
+ invalidateDirectoryCache()
29
+ const payload = {
30
+ event: eventType,
31
+ filename: filename ? String(filename) : undefined,
32
+ timestamp: Date.now(),
33
+ }
34
+ const current = dirWatchers.get(norm)
35
+ if (current) {
36
+ for (const sub of current.subscribers) {
37
+ try {
38
+ sub.send(payload)
39
+ } catch {
40
+ // ignore individual send failures
41
+ }
42
+ }
43
+ }
44
+ })
45
+ entry = { watcher, subscribers: new Set() }
46
+ dirWatchers.set(norm, entry)
47
+ } catch (err) {
48
+ console.warn('[dsh-explorer] recursive watch failed on', dir, err)
49
+ }
50
+ }
51
+
52
+ if (entry) {
53
+ entry.subscribers.add(subscriber)
54
+ }
55
+
56
+ return () => {
57
+ if (entry) {
58
+ entry.subscribers.delete(subscriber)
59
+ if (entry.subscribers.size === 0) {
60
+ try {
61
+ entry.watcher.close()
62
+ } catch {}
63
+ dirWatchers.delete(norm)
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ export function createFsWatcherServer(): {
70
+ handleSse: (req: IncomingMessage, res: ServerResponse, dir: string) => void
71
+ handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => void
72
+ close: () => void
73
+ } {
74
+ let wssPromise: Promise<any> | null = null
75
+
76
+ const getWss = async () => {
77
+ if (!wssPromise) {
78
+ wssPromise = (async () => {
79
+ try {
80
+ const wsModule: any = await import('ws')
81
+ const Server = wsModule.WebSocketServer ?? wsModule.default?.WebSocketServer
82
+ if (!Server) return null
83
+
84
+ const server = new Server({ noServer: true })
85
+ server.on('connection', (ws: any, req: IncomingMessage) => {
86
+ const url = new URL(req.url ?? '/', 'http://localhost')
87
+ const dir = url.searchParams.get('dir')
88
+ if (!dir) {
89
+ ws.close(1008, 'missing dir parameter')
90
+ return
91
+ }
92
+
93
+ const subscriber: Subscriber = {
94
+ send: (data) => {
95
+ if (ws.readyState === 1) { // 1 = OPEN
96
+ ws.send(JSON.stringify(data))
97
+ }
98
+ },
99
+ close: () => {
100
+ try { ws.close() } catch {}
101
+ },
102
+ }
103
+
104
+ const unsubscribe = subscribeToDir(dir, subscriber)
105
+ ws.on('close', () => {
106
+ unsubscribe()
107
+ })
108
+ })
109
+ return server
110
+ } catch {
111
+ return null
112
+ }
113
+ })()
114
+ }
115
+ return wssPromise
116
+ }
117
+
118
+ return {
119
+ handleSse: (req: IncomingMessage, res: ServerResponse, dir: string) => {
120
+ res.writeHead(200, {
121
+ 'Content-Type': 'text/event-stream; charset=utf-8',
122
+ 'Cache-Control': 'no-cache, no-transform',
123
+ 'Connection': 'keep-alive',
124
+ 'Access-Control-Allow-Origin': '*',
125
+ })
126
+ res.write(`data: ${JSON.stringify({ type: 'connected', dir, timestamp: Date.now() })}\n\n`)
127
+
128
+ const subscriber: Subscriber = {
129
+ send: (data) => {
130
+ try {
131
+ res.write(`data: ${JSON.stringify(data)}\n\n`)
132
+ } catch {
133
+ // Socket write failed
134
+ }
135
+ },
136
+ close: () => {
137
+ try { res.end() } catch {}
138
+ },
139
+ }
140
+
141
+ const unsubscribe = subscribeToDir(dir, subscriber)
142
+
143
+ req.on('close', () => {
144
+ unsubscribe()
145
+ })
146
+ },
147
+
148
+ handleUpgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => {
149
+ getWss().then((server) => {
150
+ if (server) {
151
+ server.handleUpgrade(req, socket, head, (ws: any) => {
152
+ server.emit('connection', ws, req)
153
+ })
154
+ } else {
155
+ socket.destroy()
156
+ }
157
+ }).catch(() => {
158
+ socket.destroy()
159
+ })
160
+ },
161
+
162
+ close: () => {
163
+ for (const entry of dirWatchers.values()) {
164
+ for (const sub of entry.subscribers) {
165
+ sub.close()
166
+ }
167
+ try {
168
+ entry.watcher.close()
169
+ } catch {}
170
+ }
171
+ dirWatchers.clear()
172
+ if (wssPromise) {
173
+ wssPromise.then((server) => {
174
+ try { server?.close() } catch {}
175
+ })
176
+ }
177
+ },
178
+ }
179
+ }
@@ -0,0 +1,161 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join, relative } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+
6
+ const execFileAsync = promisify(execFile)
7
+
8
+ export interface GitStatusEntry {
9
+ path: string
10
+ xy: string
11
+ }
12
+
13
+ export interface GitStatusResult {
14
+ isRepo: boolean
15
+ branch?: string
16
+ entries: GitStatusEntry[]
17
+ }
18
+
19
+ const gitCache = new Map<string, { at: number; data: GitStatusResult }>()
20
+ const GIT_CACHE_TTL_MS = 2000
21
+
22
+ const IGNORED_SCAN_DIRS = new Set([
23
+ 'node_modules',
24
+ '.agent',
25
+ '.git',
26
+ 'dist',
27
+ 'target',
28
+ 'build',
29
+ '.system_generated',
30
+ '.cache',
31
+ '.dsh',
32
+ '.gemini',
33
+ '.vscode',
34
+ '.idea',
35
+ ])
36
+
37
+ function parsePorcelainLines(stdout: string, prefix = ''): { branch?: string; entries: GitStatusEntry[] } {
38
+ let branch: string | undefined
39
+ const entries: GitStatusEntry[] = []
40
+
41
+ for (const rawLine of stdout.split('\n')) {
42
+ const line = rawLine.replace(/\r$/, '')
43
+ if (!line) continue
44
+ if (line.startsWith('## ')) {
45
+ const branchMatch = line.slice(3).match(/^([^.\s]+)/)
46
+ if (branchMatch) branch = branchMatch[1]
47
+ continue
48
+ }
49
+ if (line.length < 4) continue
50
+ const xy = line.slice(0, 2)
51
+ let p = line.slice(3).trim()
52
+ if (p.startsWith('"') && p.endsWith('"')) {
53
+ p = p.slice(1, -1)
54
+ }
55
+ // Handle renamed files: "R old -> new"
56
+ if (xy.startsWith('R') || xy.endsWith('R')) {
57
+ const arrowIdx = p.indexOf(' -> ')
58
+ if (arrowIdx !== -1) {
59
+ p = p.slice(arrowIdx + 4).trim()
60
+ if (p.startsWith('"') && p.endsWith('"')) {
61
+ p = p.slice(1, -1)
62
+ }
63
+ }
64
+ }
65
+ const cleanPath = p.replace(/\\/g, '/')
66
+ const finalPath = prefix ? `${prefix.replace(/\\/g, '/').replace(/\/+$/, '')}/${cleanPath}` : cleanPath
67
+ entries.push({ path: finalPath, xy })
68
+ }
69
+
70
+ return { branch, entries }
71
+ }
72
+
73
+ function findSubRepos(root: string, depth = 1, maxDepth = 3): string[] {
74
+ if (depth > maxDepth) return []
75
+ const repos: string[] = []
76
+ try {
77
+ const entries = readdirSync(root, { withFileTypes: true })
78
+ for (const ent of entries) {
79
+ if (!ent.isDirectory() || ent.name.startsWith('.') || IGNORED_SCAN_DIRS.has(ent.name)) {
80
+ continue
81
+ }
82
+ const full = join(root, ent.name)
83
+ if (existsSync(join(full, '.git'))) {
84
+ repos.push(full)
85
+ } else {
86
+ repos.push(...findSubRepos(full, depth + 1, maxDepth))
87
+ }
88
+ }
89
+ } catch {
90
+ // Ignore read errors
91
+ }
92
+ return repos
93
+ }
94
+
95
+ export async function getGitStatus(cwd: string): Promise<GitStatusResult> {
96
+ const now = Date.now()
97
+ const cached = gitCache.get(cwd)
98
+ if (cached && now - cached.at < GIT_CACHE_TTL_MS) {
99
+ return cached.data
100
+ }
101
+
102
+ // 1. Try running git directly in cwd
103
+ try {
104
+ const { stdout } = await execFileAsync(
105
+ 'git',
106
+ ['status', '--porcelain', '-b', '-unormal', '--ignored=matching'],
107
+ { cwd, maxBuffer: 10 * 1024 * 1024, timeout: 3500 }
108
+ )
109
+
110
+ const parsed = parsePorcelainLines(stdout)
111
+ const data: GitStatusResult = {
112
+ isRepo: true,
113
+ branch: parsed.branch,
114
+ entries: parsed.entries,
115
+ }
116
+ gitCache.set(cwd, { at: now, data })
117
+ return data
118
+ } catch {
119
+ // cwd is not a git repo or direct command failed. Check for nested/sub repositories.
120
+ }
121
+
122
+ // 2. Discover sub-repositories within cwd (for workspaces containing multiple repos)
123
+ try {
124
+ const subRepos = findSubRepos(cwd)
125
+ if (subRepos.length > 0) {
126
+ const allEntries: GitStatusEntry[] = []
127
+ const subResults = await Promise.all(
128
+ subRepos.map(async (repoDir) => {
129
+ const rel = relative(cwd, repoDir).replace(/\\/g, '/')
130
+ try {
131
+ const { stdout } = await execFileAsync(
132
+ 'git',
133
+ ['status', '--porcelain', '-b', '-unormal', '--ignored=matching'],
134
+ { cwd: repoDir, maxBuffer: 5 * 1024 * 1024, timeout: 3000 }
135
+ )
136
+ return parsePorcelainLines(stdout, rel).entries
137
+ } catch {
138
+ return []
139
+ }
140
+ })
141
+ )
142
+
143
+ for (const res of subResults) {
144
+ allEntries.push(...res)
145
+ }
146
+
147
+ const data: GitStatusResult = {
148
+ isRepo: true,
149
+ entries: allEntries,
150
+ }
151
+ gitCache.set(cwd, { at: now, data })
152
+ return data
153
+ }
154
+ } catch {
155
+ // Ignore
156
+ }
157
+
158
+ const fallback: GitStatusResult = { isRepo: false, entries: [] }
159
+ gitCache.set(cwd, { at: now, data: fallback })
160
+ return fallback
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,165 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http'
2
+ import type { Duplex } from 'node:stream'
3
+ import { Config, type ExplorerConfig } from './config.ts'
4
+ import { createFsWatcherServer } from './fs-watch.ts'
5
+ import { listDirectory, listDirectoryBatch } from './fs-tree.ts'
6
+ import { createDirectory, createNewFile, readFileText, removeEntry, renameEntry, writeUpload } from './fs-operations.ts'
7
+ import { getGitStatus } from './git-status.ts'
8
+ import { resolveTarget } from './path-security.ts'
9
+ import { resolveSessionCwd } from './session-path.ts'
10
+ import { readJsonBody, writeError, writeOk, writeJson, ExplorerError } from './wire.ts'
11
+
12
+ export interface Context {
13
+ webServer: {
14
+ register: (options: {
15
+ path: string
16
+ kind?: 'exact' | 'prefix'
17
+ method?: string | string[]
18
+ prefix?: boolean
19
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean | void> | boolean | void
20
+ }) => () => void
21
+ registerUpgrade: (options: {
22
+ path: string
23
+ prefix?: boolean
24
+ handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => Promise<boolean | void> | boolean | void
25
+ }) => () => void
26
+ }
27
+ effect: (fn: () => void | (() => void)) => void
28
+ get: (service: string) => unknown
29
+ }
30
+
31
+ export const name = 'dsh-explorer'
32
+ export const inject = ['webServer', 'sessions']
33
+ export { Config }
34
+
35
+ export function apply(ctx: Context, config?: ExplorerConfig): void {
36
+ const watcher = createFsWatcherServer()
37
+
38
+ ctx.effect(() => {
39
+ return () => {
40
+ watcher.close()
41
+ }
42
+ })
43
+
44
+ // WebSocket upgrade
45
+ ctx.effect(() => {
46
+ return ctx.webServer.registerUpgrade({
47
+ path: '/dsh-explorer/api/watch',
48
+ handler: (req, socket, head) => {
49
+ watcher.handleUpgrade(req, socket, head)
50
+ return true
51
+ },
52
+ })
53
+ })
54
+
55
+ // HTTP Routes
56
+ ctx.effect(() => {
57
+ return ctx.webServer.register({
58
+ kind: 'prefix',
59
+ path: '/dsh-explorer/api',
60
+ handler: async (req, res) => {
61
+ const url = new URL(req.url ?? '/', 'http://localhost')
62
+ const pathname = url.pathname
63
+
64
+ try {
65
+ if (pathname === '/dsh-explorer/api/events') {
66
+ const sessionId = url.searchParams.get('sessionId') ?? undefined
67
+ const cwd = resolveSessionCwd(ctx, sessionId)
68
+ const dirParam = url.searchParams.get('dir')
69
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd)
70
+ watcher.handleSse(req, res, targetDir)
71
+ return true
72
+ }
73
+
74
+ if (pathname === '/dsh-explorer/api/tree') {
75
+ const sessionId = url.searchParams.get('sessionId') ?? undefined
76
+ const cwd = resolveSessionCwd(ctx, sessionId)
77
+ const dirParam = url.searchParams.get('dir')
78
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd)
79
+ const max = config?.maxListingEntries ?? 2000
80
+ const listing = await listDirectory(targetDir, max)
81
+ writeOk(res, { listing })
82
+ return true
83
+ }
84
+
85
+ if (pathname === '/dsh-explorer/api/batch-tree' && req.method === 'POST') {
86
+ const body = await readJsonBody<{ paths: string[] }>(req)
87
+ if (!Array.isArray(body?.paths)) {
88
+ throw new ExplorerError('bad-request', 'paths must be an array', 400)
89
+ }
90
+ const results = await listDirectoryBatch(body.paths)
91
+ writeOk(res, { results })
92
+ return true
93
+ }
94
+
95
+ if (pathname === '/dsh-explorer/api/git-status') {
96
+ const sessionId = url.searchParams.get('sessionId') ?? undefined
97
+ const cwd = resolveSessionCwd(ctx, sessionId)
98
+ const dirParam = url.searchParams.get('dir')
99
+ const targetDir = resolveTarget(cwd, dirParam ?? cwd)
100
+ const git = await getGitStatus(targetDir)
101
+ writeOk(res, { git })
102
+ return true
103
+ }
104
+
105
+ if (pathname === '/dsh-explorer/api/read-file') {
106
+ const pathParam = url.searchParams.get('path')
107
+ if (!pathParam) {
108
+ throw new ExplorerError('bad-request', 'missing path parameter', 400)
109
+ }
110
+ const data = await readFileText(pathParam)
111
+ writeOk(res, data)
112
+ return true
113
+ }
114
+
115
+ if (pathname === '/dsh-explorer/api/new-file' && req.method === 'POST') {
116
+ const body = await readJsonBody<{ dir: string; name: string }>(req)
117
+ const created = await createNewFile(body.dir, body.name)
118
+ writeOk(res, created)
119
+ return true
120
+ }
121
+
122
+ if (pathname === '/dsh-explorer/api/mkdir' && req.method === 'POST') {
123
+ const body = await readJsonBody<{ dir: string; name: string }>(req)
124
+ const created = await createDirectory(body.dir, body.name)
125
+ writeOk(res, created)
126
+ return true
127
+ }
128
+
129
+ if (pathname === '/dsh-explorer/api/rename' && req.method === 'POST') {
130
+ const body = await readJsonBody<{ path: string; newName: string }>(req)
131
+ const renamed = await renameEntry(body.path, body.newName)
132
+ writeOk(res, renamed)
133
+ return true
134
+ }
135
+
136
+ if (pathname === '/dsh-explorer/api/delete' && req.method === 'POST') {
137
+ const body = await readJsonBody<{ path: string }>(req)
138
+ await removeEntry(body.path)
139
+ writeOk(res)
140
+ return true
141
+ }
142
+
143
+ if (pathname === '/dsh-explorer/api/upload' && req.method === 'POST') {
144
+ const dirParam = url.searchParams.get('dir')
145
+ const relParam = url.searchParams.get('relativePath')
146
+ if (!dirParam || !relParam) {
147
+ throw new ExplorerError('bad-request', 'dir and relativePath are required', 400)
148
+ }
149
+ const uploaded = await writeUpload(dirParam, relParam, req)
150
+ writeOk(res, uploaded)
151
+ return true
152
+ }
153
+
154
+ // Unhandled
155
+ writeJson(res, { ok: false, message: 'Not found' }, 404)
156
+ return true
157
+ } catch (err) {
158
+ writeError(res, err)
159
+ return true
160
+ }
161
+ },
162
+ })
163
+ })
164
+ }
165
+
@@ -0,0 +1,22 @@
1
+ import { homedir } from 'node:os'
2
+ import { isAbsolute, normalize, resolve } from 'node:path'
3
+ import { ExplorerError } from './wire.ts'
4
+
5
+ export function resolveTarget(cwd: string, targetPath: string): string {
6
+ if (!targetPath) return cwd
7
+ if (targetPath === '~' || targetPath.startsWith('~/') || targetPath.startsWith('~\\')) {
8
+ const home = homedir()
9
+ return normalize(resolve(home, targetPath.slice(targetPath.startsWith('~') ? (targetPath[1] ? 2 : 1) : 0)))
10
+ }
11
+ if (isAbsolute(targetPath)) {
12
+ return normalize(targetPath)
13
+ }
14
+ return normalize(resolve(cwd, targetPath))
15
+ }
16
+
17
+ export function assertSingleSegmentName(name: string): void {
18
+ if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
19
+ throw new ExplorerError('bad-request', 'name must be a single path segment without separators', 400)
20
+ }
21
+ }
22
+