import { createWriteStream } from 'node:fs'; import { mkdir, rename, stat, unlink } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { formatBytes } from '../core/extract.ts'; import { resolveWithin, safeComponent } from '../core/paths.ts'; import type { ApiClient, FsEntry } from './client.ts'; /** * `schulcloud fs` — the file manager ("Dateien") from the command line. * * The same tree the MCP fs_* tools show: /my, /courses/, /teams/, * /shared. Everything goes through the server's /api/fs routes; nothing here * talks to Schulcloud. */ type Out = (line: string) => void; export async function fsList(api: ApiClient, path: string, long: boolean, out: Out): Promise { const listing = await api.fsList(path); if (listing.kind === 'file' && listing.file) { out(`${listing.path}`); out(` ${formatBytes(listing.file.size)} ${listing.file.mimeType ?? 'unknown type'} ${listing.file.id}${listing.file.blocked ? ' [blocked]' : ''}`); return 0; } const directories = listing.directories ?? []; const files = listing.files ?? []; out(listing.path); for (const directory of directories) out(` ${directory.name}/${long ? ` ${directory.id}` : ''}`); for (const file of files) { const detail = long ? ` ${formatBytes(file.size).padStart(9)} ${file.id}${file.mimeType ? ` ${file.mimeType}` : ''}` : ''; out(` ${file.name}${detail}${file.blocked ? ' [blocked]' : ''}`); } if (directories.length + files.length === 0) out(' (empty)'); out(`${directories.length} folder(s), ${files.length} file(s)`); return 0; } export async function fsTree(api: ApiClient, path: string, depth: number, maxFolders: number, out: Out): Promise { const walk = await api.fsTree(path, depth, maxFolders); if (walk.kind === 'file') { out(`${walk.path} is a file.`); return 0; } const entries = walk.entries ?? []; const children = groupByParent(entries); out(walk.path); let files = 0; let bytes = 0; const visit = (parent: string, prefix: string) => { const kids = children.get(parent) ?? []; kids.forEach((kid, index) => { const last = index === kids.length - 1; const branch = last ? '└── ' : '├── '; if (kid.type === 'directory') { out(`${prefix}${branch}${kid.name}/`); visit(kid.path, `${prefix}${last ? ' ' : '│ '}`); } else { files++; bytes += kid.size ?? 0; out(`${prefix}${branch}${kid.name} (${formatBytes(kid.size ?? 0)})${kid.blocked ? ' [blocked]' : ''}`); } }); }; visit(walk.path, ''); out(`\n${files} file(s), ${formatBytes(bytes)} — ${walk.visited ?? 0} folder(s) listed`); if (walk.truncated) out(`Stopped after ${maxFolders} folders; pass --max-folders or start deeper.`); for (const failure of walk.failures ?? []) out(`could not list ${failure.path}: ${failure.reason}`); return walk.failures?.length ? 1 : 0; } export async function fsFind( api: ApiClient, name: string, path: string, type: string, maxFolders: number, long: boolean, out: Out, ): Promise { const result = await api.fsFind(name, path, type, maxFolders); for (const match of result.matches ?? []) { const suffix = match.type === 'directory' ? '/' : ''; const detail = long && match.type === 'file' ? ` ${formatBytes(match.size ?? 0)} ${match.id}` : long ? ` ${match.id}` : ''; out(`${match.path}${suffix}${detail}`); } const count = result.matches?.length ?? 0; process.stderr.write(`${count} match(es), ${result.visited ?? 0} folder(s) searched\n`); if (result.truncated) process.stderr.write(`Stopped after ${maxFolders} folders; there may be more. Narrow --path.\n`); return count > 0 ? 0 : 1; } /** * Downloads a file, or a whole folder recursively. * * A folder lands under `--out` (default: a directory named after it) with the * file manager's structure. Every component is a name from Schulcloud and so * untrusted: each goes through `safeComponent`, and the joined path through * `resolveWithin`, exactly as sync does. */ export async function fsGet( api: ApiClient, path: string, options: { out?: string; force: boolean; jobs: number }, out: Out, ): Promise { const target = await api.fsList(path); if (target.kind === 'file' && target.file) { const destination = options.out ? resolve(options.out) : resolve(safeComponent(target.file.name, target.file.id)); if (target.file.blocked) { process.stderr.write(`${target.path}: blocked by the instance virus scanner; not downloaded.\n`); return 1; } await downloadTo(api, { path: target.path }, destination); out(destination); return 0; } const rootName = target.path === '/' ? 'Dateien' : (target.path.split('/').pop() ?? 'Dateien'); const root = options.out ? resolve(options.out) : resolve(safeComponent(rootName, 'Dateien')); process.stderr.write(`Listing ${target.path} …\n`); const walk = await api.fsTree(target.path, 12, 1000); const entries = walk.entries ?? []; // Rebuild each file's name segments from its parents rather than splitting // its path: names may contain "/", which would otherwise invent folders. const segments = new Map([[walk.path, []]]); for (const entry of [...entries].sort((a, b) => a.depth - b.depth)) { const parent = segments.get(entry.parentPath); if (parent) segments.set(entry.path, [...parent, entry.name]); } const files = entries.filter((entry) => entry.type === 'file'); let downloaded = 0; let skipped = 0; let failed = 0; let bytes = 0; const queue = [...files]; const worker = async () => { for (let entry = queue.shift(); entry; entry = queue.shift()) { const parts = segments.get(entry.path); if (!parts) continue; const relative = parts.map((part) => safeComponent(part)).join('/'); const destination = resolveWithin(root, relative); if (entry.blocked) { process.stderr.write(` blocked ${relative}\n`); skipped++; continue; } // Unchanged by size: re-running a folder download resumes rather than repeats. const existing = await stat(destination).catch(() => undefined); if (!options.force && existing?.isFile() && existing.size === entry.size) { skipped++; continue; } try { await downloadTo(api, { id: entry.id, name: entry.name }, destination); downloaded++; bytes += entry.size ?? 0; process.stderr.write(` get ${relative}\n`); } catch (error) { failed++; process.stderr.write(` FAILED ${relative}: ${(error as Error).message}\n`); } } }; await Promise.all(Array.from({ length: Math.max(1, options.jobs) }, worker)); out(root); process.stderr.write( `Downloaded ${downloaded} file(s) (${formatBytes(bytes)}), ${skipped} skipped` + `${failed ? `, FAILED ${failed}` : ''} — ${walk.visited ?? 0} folder(s) listed\n`, ); if (walk.truncated) process.stderr.write('The folder is larger than one listing pass; some files were not reached.\n'); for (const failure of walk.failures ?? []) process.stderr.write(`could not list ${failure.path}: ${failure.reason}\n`); return failed > 0 || (walk.failures?.length ?? 0) > 0 ? 1 : 0; } async function downloadTo(api: ApiClient, target: { path: string } | { id: string; name: string }, destination: string) { const response = await api.fsFile(target); if (!response.body) throw new Error('empty response body'); await mkdir(dirname(destination), { recursive: true }); // A temporary neighbour, renamed into place, so an interrupted download never // leaves a half-file that the size check would later accept as complete. const temp = `${destination}.part`; try { await pipeline(Readable.fromWeb(response.body as never), createWriteStream(temp)); await rename(temp, destination); } catch (error) { await unlink(temp).catch(() => {}); throw error; } } function groupByParent(entries: FsEntry[]): Map { const children = new Map(); for (const entry of entries) { const list = children.get(entry.parentPath) ?? []; list.push(entry); children.set(entry.parentPath, list); } for (const list of children.values()) { list.sort((a, b) => a.type !== b.type ? a.type === 'directory' ? -1 : 1 : a.name.localeCompare(b.name, 'de', { numeric: true, sensitivity: 'base' }), ); } return children; }