@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
161 lines • 7.39 kB
JavaScript
// ========= 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