UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

161 lines • 7.39 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { missingOperandError } from "../../../spec/usage.js"; import { FileType, Follow, LineFacts, PathSpec } from "../../../../types.js"; import { fsStrerror, isFsError } from "../../../../errors/fs.js"; import { respelled } from "../../../../utils/key_prefix.js"; import { CycleError, resolvePath } from "../../../../utils/path.js"; import { rstripSlash } from "../../../../utils/slash.js"; import { formatOptionalRecords } from "../../utils/output.js"; import { operandsIo } from "../../utils/operands.js"; import { linkLocation, linksOf, mountRoot } from "../../utils/links.js"; import { specOf } from "../../../spec/builtins.js"; import { FlagView } from "../../../spec/flag_view.js"; import { commandFiles } from "../files.js"; import { posixPhrase } from "../../../../errors/posix.js"; // `virtual` with its parent resolved through the namespace's links: rmdir(2) // follows every component but the last, which stays as named, so a link // there is refused rather than followed. Mirrors Python's followed_parent. function followedParent(virtual, links) { if (links === null) return virtual; try { return linkLocation(links, virtual); } catch (err) { if (!(err instanceof CycleError)) throw err; console.warn(`rmdir: following ${virtual} failed: ${String(err)}`); return virtual; } } // The directories -p removes after `path`, as GNU cuts them from the // operand as typed, each with its spelling. null stands for the mount root, // which is a mount point and is never removed: `root` is the prefix of the // mount the command runs on, without its trailing slash (`mountRoot`). Each // is reached through the links in its parent, as GNU's rmdir(2) is: `rmdir // -p link/nested/leaf` removes the directory `link/nested` names. Mirrors // Python's ancestors. export function ancestors(path, cwd, root, links = null) { let typed = rstripSlash(path.rawPath) || '/'; const chain = []; while (typed.includes('/')) { let cut = typed.lastIndexOf('/'); while (cut > 0 && typed[cut] === '/') cut -= 1; typed = typed.slice(0, cut + 1); const literal = rstripSlash(resolvePath(typed, cwd)); if (!literal.startsWith(`${root}/`)) { chain.push([null, typed]); break; } // A parent linked onto another mount is out of this mount's reach, so // the name is tried here as typed. const followed = followedParent(literal, links); const virtual = followed.startsWith(`${root}/`) ? followed : literal; chain.push([respelled(PathSpec.fromStrPath(virtual), typed), typed]); } return chain; } const rmdir = async (_accessor, paths, _texts, ctx) => { const files = commandFiles(ctx, paths); if (paths.length === 0) throw missingOperandError('rmdir', null); const resolved = await files.resolveGlob(paths); const fl = new FlagView(ctx.flags, specOf('rmdir')); const verbose = fl.asBool('verbose'); const ignore = fl.asBool('ignore_fail_on_non_empty'); const lines = []; const errors = []; const links = linksOf(ctx) ?? null; const remove = async (p) => { // rmdir(2) never follows, so a link operand never reaches the // directory it points at. GNU words the two spellings apart: a bare // link is the plain ENOTDIR, while one typed with a trailing slash // gets rmdir's own "Symbolic link not followed", since the slash // asked for a directory the call refuses to resolve. No backend can // see a link, so the name plane answers. if (links !== null && links.statAt(p.virtual) !== null) { return p.rawPath.endsWith('/') ? 'Symbolic link not followed' : posixPhrase('ENOTDIR'); } let isDir = false; try { const st = await files.stat(p); isDir = st.type === FileType.DIRECTORY; } catch (exc) { if (!isFsError(exc)) throw exc; return fsStrerror(exc) ?? String(exc); } if (!isDir) return posixPhrase('ENOTDIR'); if ((await files.readdir(p)).length > 0) return posixPhrase('ENOTEMPTY'); try { await files.rmdir(p); } catch (exc) { // The listing above showed the session an empty directory, but // the dispatcher may still refuse not-empty: the hidden-remnant guard // re-raises the backend's refusal when its cascade cannot // finish (a mode-protected remnant, a visible entry appearing // mid-walk). A read-only region refuses here too. GNU's voice, not // the raw error. const code = exc.code; const detail = code === 'ENOTEMPTY' || code === 'EEXIST' ? posixPhrase('ENOTEMPTY') : fsStrerror(exc); if (detail === null) throw exc; return detail; } return null; }; for (const p of resolved) { if (verbose) lines.push(`rmdir: removing directory, '${p.rawPath}'`); const reason = await remove(p); if (reason !== null) { if (!(ignore && reason === posixPhrase('ENOTEMPTY'))) { errors.push(`rmdir: failed to remove '${p.rawPath}': ${reason}`); } continue; } if (!fl.asBool('parents')) continue; for (const [ancestor, typed] of ancestors(p, ctx.cwd.virtual, mountRoot(ctx), links)) { if (verbose) lines.push(`rmdir: removing directory, '${typed}'`); // The walk up meets the mount root, which rmdir(2) answers with EBUSY. // An ancestor already gone held the entry just removed, so it was a // keyed store's implicit prefix that vanished with its last key: it // counts as removed. const failed = ancestor === null ? posixPhrase('EBUSY') : await remove(ancestor); if (failed === null || failed === posixPhrase('ENOENT')) continue; if (!(ignore && failed === posixPhrase('ENOTEMPTY'))) { const what = failed === posixPhrase('ENOTDIR') ? '' : 'directory '; errors.push(`rmdir: failed to remove ${what}'${typed}': ${failed}`); } break; } } return [formatOptionalRecords(lines), operandsIo(errors.map((line) => `${line}\n`).join(''))]; }; export const BUILDER = { name: 'rmdir', facts: new LineFacts({ follows: Follow.NONE, subtree: true }), write: true, fn: rmdir, }; //# sourceMappingURL=rmdir.js.map