File size: 10,076 Bytes
488d96b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | /**
* File and directory utilities of the command table, all of them over the
* shell's filesystem. Listings print one entry per line: nothing here is ever
* a terminal, so the column layout a real `ls` picks for a tty would only be
* noise in a tool result.
* @module @deepseek-ai/dsh-experimental-webworker-runtime/src/shell/programs/files
*/
import picomatch from 'picomatch'
import { basename, dirname, resolve } from '../../module-system/posix-path.ts'
import { describeFailure, resolveIn } from '../fs-access.ts'
import type { ShellFileSystem, ShellProgram, ShellStats } from '../types.ts'
import { parseOptions } from './options.ts'
/** Format one entry the way `ls -l` does, with the facts the VFS actually holds. */
function longEntry(stats: ShellStats | undefined, name: string): string {
const size = String(stats?.size ?? 0).padStart(8)
const modified = new Date(stats?.mtimeMs ?? 0).toISOString().replace('T', ' ').slice(0, 16)
return `${stats?.directory === true ? 'drwxr-xr-x' : '-rw-r--r--'} ${size} ${modified} ${name}`
}
const ls: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
const operands = options.operands.length > 0 ? options.operands : ['.']
let status = 0
for (const [index, operand] of operands.entries()) {
const path = resolveIn(state.cwd, operand)
const stats = await fs.stat(path)
if (stats === undefined) {
io.err(`ls: ${operand}: No such file or directory\n`)
status = 2
continue
}
if (operands.length > 1) io.out(`${index > 0 ? '\n' : ''}${operand}:\n`)
if (!stats.directory) {
io.out(`${options.flags.has('l') ? longEntry(stats, operand) : operand}\n`)
continue
}
const entries = (await fs.list(path)).filter(entry => options.flags.has('a') || !entry.name.startsWith('.'))
for (const entry of entries) {
const shown = options.flags.has('l')
? longEntry(await fs.stat(resolve(path, entry.name)), entry.name)
: entry.name
io.out(`${shown}\n`)
}
}
return status
}
const find: ShellProgram = async (argv, io, state, fs) => {
// `find` spells multi-letter predicates with one dash, which the shared
// option parser would read as bundled short flags; this walk reads them.
const roots: string[] = []
let namePattern: string | undefined
let kind: string | undefined
let maxDepth = Number.POSITIVE_INFINITY
const words = argv.slice(1)
for (let index = 0; index < words.length; index += 1) {
const word = words[index] as string
if (word === '-name') { index += 1; namePattern = words[index]; continue }
if (word === '-type') { index += 1; kind = words[index]; continue }
if (word === '-maxdepth') { index += 1; maxDepth = Number.parseInt(words[index] ?? '', 10); continue }
if (word.startsWith('-')) {
io.err(`find: unsupported predicate ${word}\n`)
return 2
}
roots.push(word)
}
const matches = namePattern === undefined ? undefined : picomatch(namePattern, { dot: true })
let status = 0
const visit = async (path: string, display: string, depth: number): Promise<void> => {
const stats = await fs.stat(path)
if (stats === undefined) {
io.err(`find: ${display}: No such file or directory\n`)
status = 1
return
}
const selected = (matches === undefined || matches(basename(display)))
&& (kind === undefined || (kind === 'd') === stats.directory)
if (selected) io.out(`${display}\n`)
if (!stats.directory || depth >= maxDepth) return
for (const entry of await fs.list(path)) {
await visit(resolve(path, entry.name), `${display === '/' ? '' : display}/${entry.name}`, depth + 1)
}
}
for (const root of roots.length > 0 ? roots : ['.']) await visit(resolveIn(state.cwd, root), root, 0)
return status
}
const mkdir: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
let status = 0
for (const operand of options.operands) {
try {
await fs.mkdir(resolveIn(state.cwd, operand), options.flags.has('p'))
} catch (error) {
io.err(`${describeFailure('mkdir', operand, error)}\n`)
status = 1
}
}
return status
}
const rmdir: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
let status = 0
for (const operand of options.operands) {
const path = resolveIn(state.cwd, operand)
if ((await fs.list(path)).length > 0) {
io.err(`rmdir: ${operand}: Directory not empty\n`)
status = 1
continue
}
try {
await fs.remove(path, { recursive: true, force: false })
} catch (error) {
io.err(`${describeFailure('rmdir', operand, error)}\n`)
status = 1
}
}
return status
}
const rm: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
const recursive = options.flags.has('r') || options.flags.has('R')
const force = options.flags.has('f')
let status = 0
for (const operand of options.operands) {
const path = resolveIn(state.cwd, operand)
const stats = await fs.stat(path)
if (stats === undefined) {
if (force) continue
io.err(`rm: ${operand}: No such file or directory\n`)
status = 1
continue
}
if (stats.directory && !recursive) {
io.err(`rm: ${operand}: Is a directory\n`)
status = 1
continue
}
try {
await fs.remove(path, { recursive, force })
} catch (error) {
io.err(`${describeFailure('rm', operand, error)}\n`)
status = 1
}
}
return status
}
/** Copy one file or one whole subtree. */
async function copyTree(from: string, to: string, fs: ShellFileSystem): Promise<void> {
const stats = await fs.stat(from)
if (stats?.directory !== true) {
await fs.writeText(to, await fs.readText(from))
return
}
await fs.mkdir(to, true)
for (const entry of await fs.list(from)) await copyTree(resolve(from, entry.name), resolve(to, entry.name), fs)
}
/** Resolve the real destination of a copy or move: into a directory, or onto a path. */
async function destinationFor(target: string, source: string, fs: ShellFileSystem): Promise<string> {
return (await fs.stat(target))?.directory === true ? resolve(target, basename(source)) : target
}
const cp: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
const sources = options.operands.slice(0, -1)
const target = options.operands[options.operands.length - 1]
if (target === undefined || sources.length === 0) {
io.err('cp: expected a source and a destination\n')
return 2
}
const targetPath = resolveIn(state.cwd, target)
let status = 0
for (const source of sources) {
const sourcePath = resolveIn(state.cwd, source)
const stats = await fs.stat(sourcePath)
if (stats === undefined) {
io.err(`cp: ${source}: No such file or directory\n`)
status = 1
continue
}
if (stats.directory && !(options.flags.has('r') || options.flags.has('R'))) {
io.err(`cp: ${source}: Is a directory\n`)
status = 1
continue
}
try {
await copyTree(sourcePath, await destinationFor(targetPath, source, fs), fs)
} catch (error) {
io.err(`${describeFailure('cp', source, error)}\n`)
status = 1
}
}
return status
}
const mv: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
const sources = options.operands.slice(0, -1)
const target = options.operands[options.operands.length - 1]
if (target === undefined || sources.length === 0) {
io.err('mv: expected a source and a destination\n')
return 2
}
const targetPath = resolveIn(state.cwd, target)
let status = 0
for (const source of sources) {
try {
await fs.rename(resolveIn(state.cwd, source), await destinationFor(targetPath, source, fs))
} catch (error) {
io.err(`${describeFailure('mv', source, error)}\n`)
status = 1
}
}
return status
}
const touch: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
let status = 0
for (const operand of options.operands) {
const path = resolveIn(state.cwd, operand)
try {
// Rewriting the existing bytes is what advances the VFS timestamp.
await fs.writeText(path, await fs.stat(path) === undefined ? '' : await fs.readText(path))
} catch (error) {
io.err(`${describeFailure('touch', operand, error)}\n`)
status = 1
}
}
return status
}
const stat: ShellProgram = async (argv, io, state, fs) => {
const options = parseOptions(argv)
let status = 0
for (const operand of options.operands) {
const path = resolveIn(state.cwd, operand)
const stats = await fs.stat(path)
if (stats === undefined) {
io.err(`stat: ${operand}: No such file or directory\n`)
status = 1
continue
}
io.out(`${path} ${stats.directory ? 'directory' : 'file'} ${String(stats.size)} ${new Date(stats.mtimeMs).toISOString()}\n`)
}
return status
}
const dirnameProgram: ShellProgram = (argv, io) => {
for (const operand of argv.slice(1)) io.out(`${dirname(operand)}\n`)
return argv.length > 1 ? 0 : 2
}
const basenameProgram: ShellProgram = (argv, io) => {
const [, path, suffix] = argv
if (path === undefined) {
io.err('basename: expected a path\n')
return 2
}
io.out(`${basename(path, suffix)}\n`)
return 0
}
/** Refuse a utility whose effect the VFS cannot represent at all. */
const unavailable = (name: string): ShellProgram => (_argv, io) => {
io.err(`${name}: not available in the worker host\n`)
return 127
}
/** The file utilities, keyed by the name a command line uses. */
export const FILE_PROGRAMS: Readonly<Record<string, ShellProgram>> = {
ls,
find,
mkdir,
rmdir,
rm,
cp,
mv,
touch,
stat,
dirname: dirnameProgram,
basename: basenameProgram,
// Symbolic links have no representation in the VFS; refusing is honest and
// keeps a script from believing it created one.
ln: unavailable('ln'),
readlink: unavailable('readlink'),
}
|