mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 08:02:44 +00:00
Extend existing OS document associations and delivery to CSV/TSV, preserving restoration and authorization. Co-authored-by: Neil <neil@stably.ai>
227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
import type { FileDocument, MarkdownDocument } from '../../shared/filesystem-entry-types'
|
|
import { spawnBundledRipgrep } from '../ripgrep/bundled-ripgrep-spawn'
|
|
import { parseWslPath } from '../wsl'
|
|
import {
|
|
isRipgrepMissingCwdExit,
|
|
ripgrepMissingCwdError
|
|
} from '../../shared/ripgrep-process-availability'
|
|
|
|
function normalizeRelativePath(path: string): string {
|
|
return path.replace(/[\\/]+/g, '/').replace(/^\/+/, '')
|
|
}
|
|
|
|
export function isMarkdownDocumentName(name: string): boolean {
|
|
const extension = extname(name).toLowerCase()
|
|
return extension === '.md' || extension === '.mdx' || extension === '.markdown'
|
|
}
|
|
|
|
function basenameFromRelativePath(relativePath: string): string {
|
|
const normalizedPath = relativePath.replaceAll('\\', '/')
|
|
return normalizedPath.slice(normalizedPath.lastIndexOf('/') + 1)
|
|
}
|
|
|
|
function isSafeRelativePath(relativePath: string): boolean {
|
|
return !relativePath.split('/').includes('..')
|
|
}
|
|
|
|
function hasParentTraversalSegment(relativePath: string): boolean {
|
|
return relativePath.split(/[\\/]+/).includes('..')
|
|
}
|
|
|
|
function rootRelativePath(rootPath: string, filePath: string): string | null {
|
|
const resolvedRoot = resolve(rootPath)
|
|
const resolvedFile = resolve(filePath)
|
|
const relativePath = relative(resolvedRoot, resolvedFile)
|
|
if (hasParentTraversalSegment(relativePath) || isAbsolute(relativePath)) {
|
|
return null
|
|
}
|
|
return normalizeRelativePath(relativePath)
|
|
}
|
|
|
|
export function fileDocumentFromFilePath(
|
|
rootPath: string,
|
|
filePath: string,
|
|
options: { outsideRootRelativePath?: 'basename' | 'relative' } = {}
|
|
): FileDocument {
|
|
const basename = pathBasename(filePath)
|
|
const extension = extname(basename)
|
|
const relativePath =
|
|
rootRelativePath(rootPath, filePath) ??
|
|
(options.outsideRootRelativePath === 'basename'
|
|
? basename
|
|
: normalizeRelativePath(relative(rootPath, filePath)))
|
|
return {
|
|
filePath,
|
|
relativePath,
|
|
basename,
|
|
name: extension ? basename.slice(0, -extension.length) : basename
|
|
}
|
|
}
|
|
|
|
export const markdownDocumentFromFilePath = fileDocumentFromFilePath
|
|
|
|
export function markdownDocumentFromRelativePath(
|
|
rootPath: string,
|
|
relativePath: string
|
|
): MarkdownDocument | null {
|
|
const normalizedRelativePath = normalizeRelativePath(relativePath)
|
|
// Why: SSH providers should return root-relative paths; reject escape
|
|
// segments before building a synthetic absolute path for renderer use.
|
|
if (!isSafeRelativePath(normalizedRelativePath)) {
|
|
return null
|
|
}
|
|
const basename = basenameFromRelativePath(normalizedRelativePath)
|
|
if (!isMarkdownDocumentName(basename)) {
|
|
return null
|
|
}
|
|
const extension = extname(basename)
|
|
const normalizedRoot = rootPath.replace(/[\\/]+$/, '')
|
|
return {
|
|
filePath: `${normalizedRoot}/${normalizedRelativePath}`,
|
|
relativePath: normalizedRelativePath,
|
|
basename,
|
|
name: extension ? basename.slice(0, -extension.length) : basename
|
|
}
|
|
}
|
|
|
|
export function markdownDocumentsFromRelativePaths(
|
|
rootPath: string,
|
|
relativePaths: string[]
|
|
): MarkdownDocument[] {
|
|
return relativePaths
|
|
.map((relativePath) => markdownDocumentFromRelativePath(rootPath, relativePath))
|
|
.filter((document): document is MarkdownDocument => document !== null)
|
|
.sort((a, b) => a.relativePath.localeCompare(b.relativePath))
|
|
}
|
|
|
|
const MARKDOWN_LISTING_TIMEOUT_MS = 15_000
|
|
const MAX_MARKDOWN_PATH_BYTES = 1024 * 1024
|
|
|
|
export async function listMarkdownDocuments(
|
|
rootPath: string,
|
|
options: { wslDistro?: string } = {}
|
|
): Promise<MarkdownDocument[]> {
|
|
const child = spawnBundledRipgrep(
|
|
[
|
|
'--files',
|
|
'--hidden',
|
|
'--no-ignore',
|
|
'--no-config',
|
|
'--null',
|
|
'--path-separator',
|
|
'/',
|
|
// Directory-only globs preserve hidden Markdown files without traversing hidden folders.
|
|
'--glob',
|
|
'**',
|
|
'--glob',
|
|
'!**/.*/',
|
|
'--glob',
|
|
'**/.github/',
|
|
'--glob',
|
|
'!**/node_modules/',
|
|
'.'
|
|
],
|
|
{
|
|
cwd: rootPath,
|
|
wslDistro: options.wslDistro,
|
|
wslDistroForOutput: parseWslPath(rootPath)?.distro ?? options.wslDistro,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
}
|
|
)
|
|
|
|
return new Promise((resolveListing, reject) => {
|
|
const documents: MarkdownDocument[] = []
|
|
let carry = ''
|
|
let stderr = ''
|
|
let settled = false
|
|
const finish = (error?: Error): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(timer)
|
|
child.stdout?.off('data', onData)
|
|
child.stderr?.off('data', onStderr)
|
|
child.stdout?.off('error', onError)
|
|
child.stderr?.off('error', onError)
|
|
child.off('close', onClose)
|
|
child.off('error', onError)
|
|
// A spawn or pipe error can arrive after a timeout has already settled the listing.
|
|
child.on('error', ignoreLateError)
|
|
child.stdout?.on('error', ignoreLateError)
|
|
child.stderr?.on('error', ignoreLateError)
|
|
carry = ''
|
|
if (error) {
|
|
if (child.pid !== undefined) {
|
|
try {
|
|
child.kill('SIGKILL')
|
|
} catch {
|
|
// The process may have exited before the timeout or stream error arrived.
|
|
}
|
|
}
|
|
documents.length = 0
|
|
child.stdout?.resume()
|
|
child.stderr?.resume()
|
|
reject(error)
|
|
} else {
|
|
resolveListing(documents.sort((a, b) => a.relativePath.localeCompare(b.relativePath)))
|
|
}
|
|
}
|
|
const onError = (error: Error): void => finish(error)
|
|
const onStderr = (chunk: string): void => {
|
|
stderr = (stderr + chunk).slice(0, 4096)
|
|
}
|
|
const onData = (chunk: string): void => {
|
|
carry += chunk
|
|
let start = 0
|
|
let end: number
|
|
while ((end = carry.indexOf('\0', start)) !== -1) {
|
|
const path = carry.slice(start, end)
|
|
if (Buffer.byteLength(path) > MAX_MARKDOWN_PATH_BYTES) {
|
|
finish(new Error('Markdown document path exceeds the listing limit'))
|
|
return
|
|
}
|
|
if (!path.startsWith('./') || path.split('/').includes('..')) {
|
|
finish(new Error('Invalid path in Markdown document listing'))
|
|
return
|
|
}
|
|
if (isMarkdownDocumentName(path)) {
|
|
documents.push(markdownDocumentFromFilePath(rootPath, join(rootPath, path.slice(2))))
|
|
}
|
|
start = end + 1
|
|
}
|
|
carry = carry.slice(start)
|
|
if (Buffer.byteLength(carry) > MAX_MARKDOWN_PATH_BYTES) {
|
|
finish(new Error('Markdown document path exceeds the listing limit'))
|
|
}
|
|
}
|
|
const onClose = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
if (isRipgrepMissingCwdExit(code)) {
|
|
finish(ripgrepMissingCwdError(rootPath))
|
|
} else if (signal || (code !== 0 && code !== 1)) {
|
|
finish(new Error(`Markdown document listing failed (${signal ?? code}): ${stderr.trim()}`))
|
|
} else if (carry) {
|
|
finish(new Error('Incomplete path in Markdown document listing'))
|
|
} else {
|
|
finish()
|
|
}
|
|
}
|
|
const timer = setTimeout(
|
|
() => finish(new Error('Markdown document listing timed out')),
|
|
MARKDOWN_LISTING_TIMEOUT_MS
|
|
)
|
|
timer.unref?.()
|
|
child.stdout?.setEncoding('utf8')
|
|
child.stderr?.setEncoding('utf8')
|
|
child.stdout?.on('data', onData)
|
|
child.stderr?.on('data', onStderr)
|
|
child.stdout?.on('error', onError)
|
|
child.stderr?.on('error', onError)
|
|
child.once('error', onError)
|
|
child.once('close', onClose)
|
|
})
|
|
}
|
|
|
|
function ignoreLateError(): void {}
|